robust_server_socket 0.4.2 → 0.4.3
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/.rubocop.yml +9 -0
- data/.ruby-version +1 -0
- data/README.en.md +110 -396
- data/README.md +88 -115
- data/Rakefile +4 -8
- data/lib/robust_server_socket/cacher.rb +45 -35
- data/lib/robust_server_socket/client_token.rb +10 -10
- data/lib/robust_server_socket/configuration.rb +21 -9
- data/lib/robust_server_socket/modules/client_auth_protection.rb +2 -0
- data/lib/robust_server_socket/modules/{dos_attack_protection.rb → rate_limit_protection.rb} +4 -2
- data/lib/robust_server_socket/modules/replay_attack_protection.rb +15 -17
- data/lib/robust_server_socket/rate_limiter.rb +11 -26
- data/lib/robust_server_socket/secure_token/decrypt.rb +5 -7
- data/lib/robust_server_socket.rb +3 -3
- data/lib/version.rb +1 -1
- data/robust_server_socket.gemspec +12 -12
- metadata +6 -4
data/README.md
CHANGED
|
@@ -17,6 +17,8 @@ Gem для межсервисной аутентификации, использ
|
|
|
17
17
|
- **DDoS-атаками**: Необходимость ограничения частоты запросов
|
|
18
18
|
- **Boilerplate кодом**: Повторяющаяся логика валидации в каждом сервисе
|
|
19
19
|
|
|
20
|
+
#### Даже если инфраструктура находится за DMZ, в своей локальной сети, остаётся пространство для SSRF или OpenRedirect атак
|
|
21
|
+
|
|
20
22
|
### Решение
|
|
21
23
|
|
|
22
24
|
RobustServerSocket предоставляет:
|
|
@@ -24,7 +26,7 @@ RobustServerSocket предоставляет:
|
|
|
24
26
|
- **RSA-дешифрование**: Проверка подлинности токенов
|
|
25
27
|
- **Whitelist клиентов**: Только разрешённые сервисы
|
|
26
28
|
- **Защиту от replay**: Блэклист использованных токенов в Redis
|
|
27
|
-
- **Rate limiting**:
|
|
29
|
+
- **Rate limiting**: Скользящее окно запросов на клиента
|
|
28
30
|
|
|
29
31
|
## КАК ЭТО РАБОТАЕТ (HOW)
|
|
30
32
|
|
|
@@ -54,18 +56,18 @@ RobustServerSocket предоставляет:
|
|
|
54
56
|
### Поток валидации
|
|
55
57
|
|
|
56
58
|
1. **Расшифровка**: Base64 decode → RSA decrypt с приватным ключом
|
|
57
|
-
2. **Парсинг**: Извлечение `{client_name}_{
|
|
59
|
+
2. **Парсинг**: Извлечение `{client_name}_{timestamp_ms}` из токена
|
|
58
60
|
3. **Whitelist**: Проверка client_name в `allowed_services`
|
|
59
|
-
4. **Rate limit**:
|
|
61
|
+
4. **Rate limit**: Скользящее окно — проверка количества запросов за `rate_limit_window_seconds`
|
|
60
62
|
5. **Replay check**: Проверка, что токен не использован (Redis)
|
|
61
|
-
6. **Staleness**: Проверка timestamp на актуальность
|
|
63
|
+
6. **Staleness**: Проверка timestamp на актуальность (с допуском ±30 секунд на рассинхрон часов)
|
|
62
64
|
|
|
63
65
|
### Модульная система
|
|
64
66
|
|
|
65
67
|
Проверки подключаются через `using_modules`:
|
|
66
68
|
- `:client_auth_protection` — whitelist клиентов
|
|
67
|
-
- `:replay_attack_protection` — защита от повторного использования
|
|
68
|
-
- `:
|
|
69
|
+
- `:replay_attack_protection` — защита от повторного использования токена
|
|
70
|
+
- `:rate_limit_protection` — rate limiting по скользящему окну
|
|
69
71
|
|
|
70
72
|
## 📋 Содержание
|
|
71
73
|
|
|
@@ -88,21 +90,19 @@ RobustServerSocket реализует многоуровневую систем
|
|
|
88
90
|
- **Идентификация по имени**: Каждый клиент должен быть явно указан в `allowed_services`
|
|
89
91
|
|
|
90
92
|
### 3. Защита от перехвата токенов (replay-attack)
|
|
91
|
-
- **Защита от replay-attack**: использованные токены добавляются в черный
|
|
92
|
-
- **Staleness**: Токены автоматически становятся недействительными после истечения
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
93
|
+
- **Защита от replay-attack**: использованные токены добавляются в черный список, при включенном модуле `:replay_attack_protection`
|
|
94
|
+
- **Staleness**: Токены автоматически становятся недействительными после истечения `token_expiration_time`
|
|
95
|
+
- **Допуск на рассинхрон часов**: ±30 секунд (CLOCK_SKEW)
|
|
96
|
+
- **TTL блэклиста**: вычисляется автоматически как `token_expiration_time + CLOCK_SKEW`
|
|
97
|
+
|
|
98
|
+
### 4. Rate limiting
|
|
99
|
+
- **Скользящее окно**: при включенном модуле `:rate_limit_protection` — точный подсчёт запросов без граничного burst-эффекта фиксированного окна
|
|
100
|
+
- **Fail-open стратегия**: если Redis недоступен, запросы пропускаются (для надёжности сервиса)
|
|
101
|
+
- **Изоляция по клиентам**: лимит считается отдельно для каждого `client_name`
|
|
102
|
+
|
|
103
103
|
### 5. Защита от SSL stripping MITM attack
|
|
104
104
|
- **Принудительное HTTPS на сервере**: Все запросы должны быть совершены по HTTPS, чтобы защитить токены от перехвата
|
|
105
|
-
- **Включается на
|
|
105
|
+
- **Включается на RobustClientSocket, ключём `ssl_verify: true`**
|
|
106
106
|
|
|
107
107
|
## 📦 Установка
|
|
108
108
|
|
|
@@ -114,6 +114,7 @@ gem 'robust_server_socket'
|
|
|
114
114
|
```ruby
|
|
115
115
|
gem 'robust_client_socket'
|
|
116
116
|
```
|
|
117
|
+
|
|
117
118
|
## ⚙️ Конфигурация
|
|
118
119
|
|
|
119
120
|
Создайте файл `config/initializers/robust_server_socket.rb`:
|
|
@@ -121,140 +122,119 @@ gem 'robust_client_socket'
|
|
|
121
122
|
```ruby
|
|
122
123
|
RobustServerSocket.configure do |c|
|
|
123
124
|
c.using_modules = %i[
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
client_auth_protection
|
|
126
|
+
replay_attack_protection
|
|
127
|
+
rate_limit_protection
|
|
127
128
|
]
|
|
128
|
-
|
|
129
|
+
|
|
129
130
|
# Приватный ключ сервиса (RSA-2048 или выше)
|
|
130
131
|
c.private_key = ENV['ROBUST_SERVER_PRIVATE_KEY']
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
|
|
133
|
+
# Время жизни токена в секундах (должно совпадать с TTL на клиенте)
|
|
134
|
+
c.token_expiration_time = 10
|
|
135
|
+
|
|
133
136
|
# Список разрешённых сервисов (whitelist)
|
|
134
|
-
# Должен совпадать с
|
|
135
|
-
# Для client_auth_protection
|
|
137
|
+
# Должен совпадать с service_name RobustClientSocket клиента
|
|
136
138
|
c.allowed_services = %w[core payments notifications]
|
|
137
|
-
|
|
138
|
-
# Redis для
|
|
139
|
+
|
|
140
|
+
# Redis для replay_attack_protection и rate_limit_protection
|
|
139
141
|
c.redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
|
|
140
142
|
c.redis_pass = ENV['REDIS_PASSWORD']
|
|
141
143
|
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
c.
|
|
145
|
-
# Размер временного окна в секундах (по умолчанию: 60)
|
|
146
|
-
c.rate_limit_window_seconds = 60
|
|
144
|
+
# rate_limit_protection
|
|
145
|
+
c.rate_limit_max_requests = 100 # максимум запросов в окне (по умолчанию: 100)
|
|
146
|
+
c.rate_limit_window_seconds = 60 # размер окна в секундах (по умолчанию: 60)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
# Загрузка конфигурации с валидацией
|
|
150
149
|
RobustServerSocket.load!
|
|
151
150
|
```
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
|
|
166
|
-
|
|
151
|
+
|
|
152
|
+
### Опции конфигурации
|
|
153
|
+
|
|
154
|
+
| Параметр | Тип | Обязательный | Default | Описание |
|
|
155
|
+
|-----------------------------|---------|-------------|-------------------------------------------------------------------------------------|-------------------------------------------------|
|
|
156
|
+
| `private_key` | String | ✅ | — | Приватный RSA ключ сервиса (RSA-2048 или выше) |
|
|
157
|
+
| `token_expiration_time` | Integer | ✅ | 10 | Время жизни токена в секундах |
|
|
158
|
+
| `allowed_services` | Array | ✅ | — | Список разрешённых сервисов (whitelist) |
|
|
159
|
+
| `redis_url` | String | ✅ | — | URL для подключения к Redis |
|
|
160
|
+
| `redis_pass` | String | ❌ | nil | Пароль для Redis |
|
|
161
|
+
| `using_modules` | Array | ❌ | `[:client_auth_protection, :rate_limit_protection, :replay_attack_protection]` | Используемые модули |
|
|
162
|
+
| `rate_limit_max_requests` | Integer | ❌ | 100 | Максимальное количество запросов в окне |
|
|
163
|
+
| `rate_limit_window_seconds` | Integer | ❌ | 60 | Размер временного окна в секундах |
|
|
164
|
+
|
|
165
|
+
> `store_used_token_time` больше не является конфигурируемым — вычисляется автоматически как `token_expiration_time + 30` (CLOCK_SKEW).
|
|
166
|
+
|
|
167
|
+
### Совместимость с RobustClientSocket
|
|
168
|
+
|
|
169
|
+
Токен содержит таймстамп в **миллисекундах**. RobustClientSocket начиная с версии X.X должен генерировать:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Устаревший `Time.now.utc.to_i` (секунды) приведёт к тому, что все токены будут отклонены как `stale`.
|
|
167
176
|
|
|
168
177
|
## 🚀 Использование
|
|
169
178
|
|
|
170
179
|
### Базовая авторизация
|
|
171
180
|
|
|
172
181
|
```ruby
|
|
173
|
-
# В контроллере или middleware
|
|
174
182
|
class ApiController < ApplicationController
|
|
175
183
|
before_action :authenticate_service!
|
|
176
|
-
|
|
184
|
+
|
|
177
185
|
private
|
|
178
|
-
|
|
186
|
+
|
|
179
187
|
def authenticate_service!
|
|
180
|
-
# Хедер, прописанный в RobustClientSocket (SECURE-TOKEN default)
|
|
181
188
|
token = request.headers['SECURE-TOKEN']&.sub(/^Bearer /, '')
|
|
182
|
-
|
|
183
|
-
@current_service = RobustServerSocket::ClientToken.validate!(token) # bang method (рейзит ошибки)
|
|
189
|
+
@current_service = RobustServerSocket::ClientToken.validate!(token)
|
|
184
190
|
rescue RobustServerSocket::ClientToken::InvalidToken
|
|
185
191
|
render json: { error: 'Invalid token' }, status: :unauthorized
|
|
186
|
-
rescue RobustServerSocket::
|
|
192
|
+
rescue RobustServerSocket::Modules::ClientAuthProtection::UnauthorizedClient
|
|
187
193
|
render json: { error: 'Unauthorized service' }, status: :forbidden
|
|
188
|
-
rescue RobustServerSocket::
|
|
194
|
+
rescue RobustServerSocket::Modules::ReplayAttackProtection::UsedToken
|
|
189
195
|
render json: { error: 'Token already used' }, status: :unauthorized
|
|
190
|
-
rescue RobustServerSocket::
|
|
196
|
+
rescue RobustServerSocket::Modules::ReplayAttackProtection::StaleToken
|
|
191
197
|
render json: { error: 'Token expired' }, status: :unauthorized
|
|
192
198
|
rescue RobustServerSocket::RateLimiter::RateLimitExceeded => e
|
|
193
199
|
render json: { error: e.message }, status: :too_many_requests
|
|
194
200
|
end
|
|
195
|
-
|
|
196
|
-
def authenticate_service
|
|
197
|
-
token = request.headers['SECURE-TOKEN']&.sub(/^Bearer /, '')
|
|
198
|
-
@current_service = RobustServerSocket::ClientToken.valid?(token) # не рейзит
|
|
199
|
-
|
|
200
|
-
if @current_service
|
|
201
|
-
# Токен валиден
|
|
202
|
-
else
|
|
203
|
-
# Токен невалиден
|
|
204
|
-
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
201
|
end
|
|
208
202
|
```
|
|
209
203
|
|
|
210
|
-
###
|
|
204
|
+
### valid? (не рейзит)
|
|
211
205
|
|
|
212
206
|
```ruby
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
client_token = RobustServerSocket::ClientToken.new(token_string)
|
|
207
|
+
token = request.headers['SECURE-TOKEN']&.sub(/^Bearer /, '')
|
|
208
|
+
client_token = RobustServerSocket::ClientToken.new(token)
|
|
216
209
|
|
|
217
|
-
# Проверка валидности (возвращает true/false)
|
|
218
210
|
if client_token.valid?
|
|
219
|
-
# Получение имени клиента
|
|
220
211
|
client_name = client_token.client
|
|
221
|
-
puts "Authorized client: #{client_name}"
|
|
222
212
|
else
|
|
223
|
-
# Токен невалиден
|
|
224
213
|
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
225
214
|
end
|
|
226
|
-
|
|
227
|
-
# Быстрая валидация с исключениями
|
|
228
|
-
begin
|
|
229
|
-
service_token = RobustServerSocket::ClientToken.validate!(token_string)
|
|
230
|
-
client_name = service_token.client
|
|
231
|
-
rescue => e
|
|
232
|
-
# Обработка специфичных ошибок
|
|
233
|
-
end
|
|
234
215
|
```
|
|
235
216
|
|
|
236
217
|
## ❌ Обработка ошибок
|
|
237
218
|
|
|
238
219
|
### Типы исключений
|
|
239
220
|
|
|
240
|
-
| Исключение
|
|
241
|
-
|
|
242
|
-
| `InvalidToken`
|
|
243
|
-
| `UnauthorizedClient`
|
|
244
|
-
| `UsedToken`
|
|
245
|
-
| `StaleToken`
|
|
246
|
-
| `RateLimitExceeded`
|
|
221
|
+
| Исключение | Причина | HTTP статус |
|
|
222
|
+
|---------------------------------------------------------|----------------------------------------------|-------------|
|
|
223
|
+
| `ClientToken::InvalidToken` | Токен не расшифрован или неверный формат | 401 |
|
|
224
|
+
| `Modules::ClientAuthProtection::UnauthorizedClient` | Клиент не в whitelist | 403 |
|
|
225
|
+
| `Modules::ReplayAttackProtection::UsedToken` | Токен уже был использован | 401 |
|
|
226
|
+
| `Modules::ReplayAttackProtection::StaleToken` | Токен истёк или из будущего (>30s) | 401 |
|
|
227
|
+
| `RateLimiter::RateLimitExceeded` | Превышен лимит запросов | 429 |
|
|
247
228
|
|
|
248
229
|
### Централизованная обработка
|
|
249
230
|
|
|
250
231
|
```ruby
|
|
251
|
-
# В ApplicationController
|
|
252
232
|
rescue_from RobustServerSocket::ClientToken::InvalidToken,
|
|
253
|
-
RobustServerSocket::
|
|
254
|
-
RobustServerSocket::
|
|
233
|
+
RobustServerSocket::Modules::ReplayAttackProtection::UsedToken,
|
|
234
|
+
RobustServerSocket::Modules::ReplayAttackProtection::StaleToken,
|
|
255
235
|
with: :unauthorized_response
|
|
256
236
|
|
|
257
|
-
rescue_from RobustServerSocket::
|
|
237
|
+
rescue_from RobustServerSocket::Modules::ClientAuthProtection::UnauthorizedClient,
|
|
258
238
|
with: :forbidden_response
|
|
259
239
|
|
|
260
240
|
rescue_from RobustServerSocket::RateLimiter::RateLimitExceeded,
|
|
@@ -263,26 +243,17 @@ rescue_from RobustServerSocket::RateLimiter::RateLimitExceeded,
|
|
|
263
243
|
private
|
|
264
244
|
|
|
265
245
|
def unauthorized_response(exception)
|
|
266
|
-
render json: {
|
|
267
|
-
error: 'Authentication failed',
|
|
268
|
-
message: exception.message,
|
|
269
|
-
type: exception.class.name
|
|
270
|
-
}, status: :unauthorized
|
|
246
|
+
render json: { error: 'Authentication failed', message: exception.message }, status: :unauthorized
|
|
271
247
|
end
|
|
272
248
|
|
|
273
249
|
def forbidden_response(exception)
|
|
274
|
-
render json: {
|
|
275
|
-
error: 'Access denied',
|
|
276
|
-
message: exception.message,
|
|
277
|
-
type: exception.class.name
|
|
278
|
-
}, status: :forbidden
|
|
250
|
+
render json: { error: 'Access denied', message: exception.message }, status: :forbidden
|
|
279
251
|
end
|
|
280
252
|
|
|
281
253
|
def rate_limit_response(exception)
|
|
282
254
|
render json: {
|
|
283
255
|
error: 'Too many requests',
|
|
284
256
|
message: exception.message,
|
|
285
|
-
type: exception.class.name,
|
|
286
257
|
retry_after: RobustServerSocket.configuration.rate_limit_window_seconds
|
|
287
258
|
}, status: :too_many_requests
|
|
288
259
|
end
|
|
@@ -290,8 +261,6 @@ end
|
|
|
290
261
|
|
|
291
262
|
## 🤝 Интеграция с RobustClientSocket
|
|
292
263
|
|
|
293
|
-
Для полноценной работы необходимо настроить клиентскую часть:
|
|
294
|
-
|
|
295
264
|
```ruby
|
|
296
265
|
# На клиенте (RobustClientSocket)
|
|
297
266
|
RobustClientSocket.configure do |c|
|
|
@@ -299,21 +268,25 @@ RobustClientSocket.configure do |c|
|
|
|
299
268
|
c.keychain = {
|
|
300
269
|
payments: {
|
|
301
270
|
base_uri: 'https://payments.example.com',
|
|
302
|
-
public_key: '-----BEGIN PUBLIC KEY-----...'
|
|
271
|
+
public_key: '-----BEGIN PUBLIC KEY-----...'
|
|
303
272
|
}
|
|
304
273
|
}
|
|
305
274
|
end
|
|
306
275
|
|
|
307
276
|
# На сервере (RobustServerSocket)
|
|
308
277
|
RobustServerSocket.configure do |c|
|
|
309
|
-
c.allowed_services = %w[core]
|
|
310
|
-
c.private_key = '-----BEGIN PRIVATE KEY-----...'
|
|
278
|
+
c.allowed_services = %w[core]
|
|
279
|
+
c.private_key = '-----BEGIN PRIVATE KEY-----...'
|
|
311
280
|
end
|
|
312
281
|
```
|
|
313
282
|
|
|
283
|
+
## 🗺️ TODO
|
|
284
|
+
|
|
285
|
+
- [ ] **Per-client ключи для rate limiter** — настраиваемые индивидуальные лимиты для каждого `client_name` вместо единого глобального лимита
|
|
286
|
+
|
|
314
287
|
## 📚 Дополнительные ресурсы
|
|
315
288
|
|
|
316
|
-
- [RobustClientSocket
|
|
289
|
+
- [RobustClientSocket](https://github.com/tee0zed/robust_client_socket)
|
|
317
290
|
- [RSA encryption best practices](https://www.openssl.org/docs/)
|
|
318
291
|
- [Redis security guide](https://redis.io/topics/security)
|
|
319
292
|
|
data/Rakefile
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
t.libs << "test"
|
|
8
|
-
t.libs << "lib"
|
|
9
|
-
t.test_files = FileList["test/**/test_*.rb"]
|
|
10
|
-
end
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
11
7
|
|
|
12
|
-
task default: :
|
|
8
|
+
task default: :spec
|
|
@@ -1,35 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RobustServerSocket
|
|
2
|
-
module Cacher
|
|
4
|
+
module Cacher # rubocop:disable Metrics/ModuleLength
|
|
3
5
|
class RedisConnectionError < StandardError; end
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
# Atomically validate token: check expiration and usage, then mark as used
|
|
7
|
-
# Returns: 'ok', 'stale', or 'used'
|
|
8
|
-
def atomic_validate_and_log(key, ttl, timestamp, expiration_time)
|
|
9
|
-
current_time = Time.now.utc.to_i
|
|
7
|
+
CLOCK_SKEW_MS = 30_000
|
|
10
8
|
|
|
9
|
+
class << self # rubocop:disable Metrics/ClassLength
|
|
10
|
+
def atomic_validate_and_log(key, ttl, timestamp_ms, expiration_time)
|
|
11
|
+
current_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
11
12
|
redis.with do |conn|
|
|
12
|
-
conn.eval(
|
|
13
|
-
|
|
14
|
-
keys: [key],
|
|
15
|
-
argv: [ttl, timestamp, expiration_time, current_time]
|
|
16
|
-
)
|
|
13
|
+
conn.eval(lua_atomic_validate_and_log, keys: [key],
|
|
14
|
+
argv: [ttl, timestamp_ms, expiration_time, current_ms])
|
|
17
15
|
end
|
|
18
16
|
rescue ::Redis::BaseConnectionError => e
|
|
19
17
|
handle_redis_error(e, 'atomic_validate_and_log')
|
|
20
18
|
raise RedisConnectionError, "Failed to validate token: #{e.message}"
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
def
|
|
21
|
+
def incr_sliding_window_count(key, window_seconds)
|
|
22
|
+
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
24
23
|
redis.with do |conn|
|
|
25
|
-
conn.
|
|
26
|
-
|
|
27
|
-
pipeline.expire(key, ttl_seconds)
|
|
28
|
-
end
|
|
24
|
+
conn.eval(lua_sliding_window, keys: [key],
|
|
25
|
+
argv: [now_ns, window_seconds * 1_000_000_000, window_seconds, now_ns.to_s])
|
|
29
26
|
end
|
|
30
27
|
rescue ::Redis::BaseConnectionError => e
|
|
31
|
-
handle_redis_error(e, '
|
|
32
|
-
raise RedisConnectionError, "Failed to
|
|
28
|
+
handle_redis_error(e, 'incr_sliding_window_count')
|
|
29
|
+
raise RedisConnectionError, "Failed to count sliding window: #{e.message}"
|
|
33
30
|
end
|
|
34
31
|
|
|
35
32
|
def get(key)
|
|
@@ -53,52 +50,65 @@ module RobustServerSocket
|
|
|
53
50
|
redis.with(&block)
|
|
54
51
|
rescue ::Redis::BaseConnectionError => e
|
|
55
52
|
handle_redis_error(e, 'with_redis')
|
|
56
|
-
raise
|
|
53
|
+
raise RedisConnectionError, "Redis operation failed: #{e.message}"
|
|
57
54
|
end
|
|
58
55
|
|
|
59
56
|
# Clear cached Redis connection pool (useful for hot reloading in development)
|
|
60
57
|
def clear_redis_pool_cache!
|
|
61
|
-
@
|
|
58
|
+
@redis = nil
|
|
62
59
|
end
|
|
63
60
|
|
|
64
61
|
private
|
|
65
62
|
|
|
63
|
+
def lua_sliding_window
|
|
64
|
+
<<~LUA
|
|
65
|
+
local key = KEYS[1]
|
|
66
|
+
local now_ns = tonumber(ARGV[1])
|
|
67
|
+
local window_ns = tonumber(ARGV[2])
|
|
68
|
+
local window_s = tonumber(ARGV[3])
|
|
69
|
+
local member = ARGV[4]
|
|
70
|
+
|
|
71
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ns - window_ns)
|
|
72
|
+
redis.call('ZADD', key, now_ns, member)
|
|
73
|
+
redis.call('EXPIRE', key, window_s)
|
|
74
|
+
return redis.call('ZCARD', key)
|
|
75
|
+
LUA
|
|
76
|
+
end
|
|
77
|
+
|
|
66
78
|
def lua_atomic_validate_and_log
|
|
67
79
|
<<~LUA
|
|
68
80
|
local key = KEYS[1]
|
|
69
81
|
local ttl = tonumber(ARGV[1])
|
|
70
|
-
local
|
|
71
|
-
local
|
|
72
|
-
local
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if expiration_time <= (current_time - timestamp) then
|
|
82
|
+
local timestamp_ms = tonumber(ARGV[2])
|
|
83
|
+
local expiration_ms = tonumber(ARGV[3]) * 1000
|
|
84
|
+
local current_ms = tonumber(ARGV[4])
|
|
85
|
+
|
|
86
|
+
if timestamp_ms > current_ms + #{CLOCK_SKEW_MS} then
|
|
76
87
|
return 'stale'
|
|
77
88
|
end
|
|
78
|
-
|
|
89
|
+
|
|
90
|
+
if expiration_ms <= (current_ms - timestamp_ms) then
|
|
91
|
+
return 'stale'
|
|
92
|
+
end
|
|
93
|
+
|
|
79
94
|
-- Check if token was already used
|
|
80
95
|
local current = redis.call('GET', key)
|
|
81
96
|
if current and tonumber(current) > 0 then
|
|
82
97
|
return 'used'
|
|
83
98
|
end
|
|
84
|
-
|
|
99
|
+
|
|
85
100
|
-- Mark token as used
|
|
86
101
|
redis.call('INCRBY', key, 1)
|
|
87
102
|
redis.call('EXPIRE', key, ttl)
|
|
88
|
-
|
|
103
|
+
|
|
89
104
|
return 'ok'
|
|
90
105
|
LUA
|
|
91
106
|
end
|
|
92
107
|
|
|
93
|
-
def ttl_seconds
|
|
94
|
-
# `+ 10` secs, for token storing and expiration check validity
|
|
95
|
-
::RobustServerSocket.configuration.token_expiration_time + 10
|
|
96
|
-
end
|
|
97
|
-
|
|
98
108
|
# Cache Redis connection pool at module level for the lifetime of the Rails process
|
|
99
109
|
# This avoids recreating the connection pool on every Redis operation
|
|
100
110
|
def redis
|
|
101
|
-
@
|
|
111
|
+
@redis ||= ::ConnectionPool::Wrapper.new(**pool_config) do
|
|
102
112
|
::Redis.new(redis_config)
|
|
103
113
|
end
|
|
104
114
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RobustServerSocket
|
|
2
4
|
class ClientToken
|
|
3
5
|
TOKEN_REGEXP = /\A(.+)_(\d{10,})\z/.freeze
|
|
@@ -5,9 +7,7 @@ module RobustServerSocket
|
|
|
5
7
|
InvalidToken = Class.new(StandardError)
|
|
6
8
|
|
|
7
9
|
def self.validate!(secure_token)
|
|
8
|
-
new(secure_token).tap
|
|
9
|
-
instance.validate!
|
|
10
|
-
end
|
|
10
|
+
new(secure_token).tap(&:validate!)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def initialize(secure_token)
|
|
@@ -16,12 +16,13 @@ module RobustServerSocket
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def validate!
|
|
19
|
-
raise InvalidToken unless validate_decrypted_token
|
|
20
19
|
modules_checks!
|
|
20
|
+
rescue SecureToken::InvalidToken => e
|
|
21
|
+
raise InvalidToken, e.message
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def valid?
|
|
24
|
-
|
|
25
|
+
modules_checks
|
|
25
26
|
rescue StandardError
|
|
26
27
|
false
|
|
27
28
|
end
|
|
@@ -45,10 +46,6 @@ module RobustServerSocket
|
|
|
45
46
|
@decrypted_token ||= SecureToken::Decrypt.call(@secure_token)
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
def validate_decrypted_token
|
|
49
|
-
!!decrypted_token
|
|
50
|
-
end
|
|
51
|
-
|
|
52
49
|
private
|
|
53
50
|
|
|
54
51
|
def allowed_clients
|
|
@@ -67,6 +64,7 @@ module RobustServerSocket
|
|
|
67
64
|
@split_token ||= begin
|
|
68
65
|
match_data = decrypted_token.to_s.match(TOKEN_REGEXP)
|
|
69
66
|
raise InvalidToken, 'Invalid token format' unless match_data
|
|
67
|
+
|
|
70
68
|
match_data.captures
|
|
71
69
|
end
|
|
72
70
|
end
|
|
@@ -75,7 +73,6 @@ module RobustServerSocket
|
|
|
75
73
|
RobustServerSocket.configuration.token_expiration_time
|
|
76
74
|
end
|
|
77
75
|
|
|
78
|
-
|
|
79
76
|
# Do we need it? It would be useful only if public_key compromised
|
|
80
77
|
# def secure_compare(a, b)
|
|
81
78
|
# return false unless a.bytesize == b.bytesize
|
|
@@ -89,6 +86,9 @@ module RobustServerSocket
|
|
|
89
86
|
raise InvalidToken, 'Token cannot be empty' if token.empty?
|
|
90
87
|
raise InvalidToken, 'Token too long' if token.length > 2048
|
|
91
88
|
|
|
89
|
+
# Check for null-byte injection
|
|
90
|
+
raise InvalidToken, 'Token contains invalid characters' if token.include?("\x00")
|
|
91
|
+
|
|
92
92
|
token
|
|
93
93
|
end
|
|
94
94
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RobustServerSocket
|
|
2
4
|
module Configuration
|
|
3
5
|
MIN_KEY_SIZE = 2048
|
|
4
6
|
|
|
7
|
+
ConfigurationError = Class.new(StandardError)
|
|
8
|
+
|
|
5
9
|
attr_reader :configuration, :configured
|
|
6
10
|
|
|
7
11
|
def _push_modules_check_code(code)
|
|
@@ -15,6 +19,7 @@ module RobustServerSocket
|
|
|
15
19
|
def configure
|
|
16
20
|
@configuration ||= ConfigStore.new
|
|
17
21
|
yield(configuration)
|
|
22
|
+
validate_configuration!
|
|
18
23
|
validate_key_security!
|
|
19
24
|
|
|
20
25
|
@configured = true
|
|
@@ -37,13 +42,25 @@ module RobustServerSocket
|
|
|
37
42
|
|
|
38
43
|
private
|
|
39
44
|
|
|
45
|
+
def validate_configuration!
|
|
46
|
+
validate_positive!(:rate_limit_max_requests)
|
|
47
|
+
validate_positive!(:rate_limit_window_seconds)
|
|
48
|
+
validate_positive!(:token_expiration_time)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_positive!(attr)
|
|
52
|
+
return if configuration.public_send(attr).to_i.positive?
|
|
53
|
+
|
|
54
|
+
raise ConfigurationError, "#{attr} must be positive"
|
|
55
|
+
end
|
|
56
|
+
|
|
40
57
|
def validate_key_security!
|
|
41
58
|
key = ::OpenSSL::PKey::RSA.new(configuration.private_key)
|
|
42
59
|
key_bits = key.n.num_bits
|
|
43
60
|
|
|
44
61
|
if key_bits < MIN_KEY_SIZE
|
|
45
62
|
raise SecurityError,
|
|
46
|
-
|
|
63
|
+
"RSA key size (#{key_bits} bits) below minimum (#{MIN_KEY_SIZE} bits)"
|
|
47
64
|
end
|
|
48
65
|
rescue ::OpenSSL::PKey::RSAError => e
|
|
49
66
|
raise SecurityError, "Invalid private key: #{e.message}"
|
|
@@ -51,7 +68,8 @@ module RobustServerSocket
|
|
|
51
68
|
end
|
|
52
69
|
|
|
53
70
|
class ConfigStore
|
|
54
|
-
attr_accessor :allowed_services, :private_key, :token_expiration_time,
|
|
71
|
+
attr_accessor :allowed_services, :private_key, :token_expiration_time,
|
|
72
|
+
:redis_url, :redis_pass,
|
|
55
73
|
:rate_limit_max_requests, :rate_limit_window_seconds, :using_modules
|
|
56
74
|
|
|
57
75
|
attr_reader :_modules_check_rows, :_bang_modules_check_rows
|
|
@@ -59,14 +77,8 @@ module RobustServerSocket
|
|
|
59
77
|
def initialize
|
|
60
78
|
@rate_limit_max_requests = 100
|
|
61
79
|
@rate_limit_window_seconds = 60
|
|
62
|
-
@store_used_token_time = 600
|
|
63
80
|
@token_expiration_time = 10
|
|
64
|
-
@using_modules = %i[
|
|
65
|
-
client_auth_protection
|
|
66
|
-
dos_attack_protection
|
|
67
|
-
replay_attack_protection
|
|
68
|
-
]
|
|
69
|
-
|
|
81
|
+
@using_modules = %i[client_auth_protection rate_limit_protection replay_attack_protection]
|
|
70
82
|
@_modules_check_rows = []
|
|
71
83
|
@_bang_modules_check_rows = []
|
|
72
84
|
end
|