Skip to content

Commit 0c301f8

Browse files
committed
rack attack
1 parent 1240f62 commit 0c301f8

10 files changed

Lines changed: 429 additions & 44 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@
4242
/frontend/node_modules/
4343
/frontend/package-lock.json
4444
/public/frontend
45+
.yardoc

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ gem 'html2rss-configs', github: 'html2rss/html2rss-configs'
1414

1515
gem 'base64'
1616
gem 'parallel'
17+
gem 'rack-attack'
1718
gem 'rack-cache'
1819
gem 'rack-timeout'
1920
gem 'rack-unreloader'

Gemfile.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ GEM
132132
websocket-driver (>= 0.6.0)
133133
racc (1.8.1)
134134
rack (3.2.0)
135+
rack-attack (6.7.0)
136+
rack (>= 1.0, < 4)
135137
rack-cache (1.17.0)
136138
rack (>= 0.4)
137139
rack-test (2.2.0)
@@ -241,6 +243,7 @@ DEPENDENCIES
241243
html2rss-configs!
242244
parallel
243245
puma
246+
rack-attack
244247
rack-cache
245248
rack-test
246249
rack-timeout
@@ -312,6 +315,7 @@ CHECKSUMS
312315
puppeteer-ruby (0.45.6) sha256=cb86f7b4f6f8658a709ae1a305e820bdb009548e6beff6675489926f9ceb5995
313316
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
314317
rack (3.2.0) sha256=79cd21514d696c59d61fae02e62900f087aac2d053fdc77d45f4e91b94fb3612
318+
rack-attack (6.7.0) sha256=3ca47e8f66cd33b2c96af53ea4754525cd928ed3fa8da10ee6dad0277791d77c
315319
rack-cache (1.17.0) sha256=49592f3ef2173b0f5524df98bb801fb411e839869e7ce84ac428dc492bf0eb90
316320
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
317321
rack-timeout (0.7.0) sha256=757337e9793cca999bb73a61fe2a7d4280aa9eefbaf787ce3b98d860749c87d9

app/auth.rb

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
require_relative 'local_config'
1010

1111
module Html2rss
12+
##
13+
# Web application modules for html2rss
1214
module Web
1315
##
1416
# Unified authentication system for html2rss-web
@@ -20,8 +22,8 @@ module Auth
2022

2123
##
2224
# Authenticate a request and return account data if valid
23-
# @param request [Roda::Request] the request object
24-
# @return [Hash, nil] account data if authenticated, nil otherwise
25+
# @param request [Roda::Request] request object
26+
# @return [Hash, nil] account data if authenticated
2527
def authenticate(request)
2628
token = extract_token(request)
2729
return nil unless token
@@ -31,12 +33,19 @@ def authenticate(request)
3133

3234
##
3335
# Get account data by token
34-
# @param token [String] the authentication token
35-
# @return [Hash, nil] account data if found, nil otherwise
36+
# @param token [String] authentication token
37+
# @return [Hash, nil] account data if found
3638
def get_account(token)
3739
return nil unless token
3840

39-
accounts.find { |account| account[:token] == token }
41+
token_index[token]
42+
end
43+
44+
##
45+
# Get token index for O(1) lookups
46+
# @return [Hash] token to account mapping
47+
def token_index
48+
@token_index ||= accounts.each_with_object({}) { |account, hash| hash[account[:token]] = account } # rubocop:disable ThreadSafety/ClassInstanceVariable
4049
end
4150

4251
##
@@ -74,6 +83,7 @@ def generate_feed_id(username, url, token)
7483
def generate_feed_token(username, url, expires_in: DEFAULT_TOKEN_EXPIRY)
7584
secret_key = self.secret_key
7685
return nil unless secret_key
86+
return nil unless valid_username?(username) && valid_url?(url)
7787

7888
payload = create_token_payload(username, url, expires_in)
7989
signature = create_hmac_signature(secret_key, payload)
@@ -96,9 +106,9 @@ def create_hmac_signature(secret_key, payload)
96106

97107
##
98108
# Validate a feed token and return account data if valid
99-
# @param feed_token [String] the feed token to validate
100-
# @param url [String] the URL being accessed
101-
# @return [Hash, nil] account data if valid, nil otherwise
109+
# @param feed_token [String] feed token to validate
110+
# @param url [String] URL being accessed
111+
# @return [Hash, nil] account data if valid
102112
def validate_feed_token(feed_token, url)
103113
return nil unless feed_token && url
104114

@@ -122,7 +132,21 @@ def verify_token_signature(token_data)
122132
return false unless secret_key
123133

124134
expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, token_data[:payload].to_json)
125-
token_data[:signature] == expected_signature
135+
secure_compare(token_data[:signature], expected_signature)
136+
end
137+
138+
##
139+
# Constant-time string comparison to prevent timing attacks
140+
# @param first_string [String] first string
141+
# @param second_string [String] second string
142+
# @return [Boolean] true if strings are equal
143+
def secure_compare(first_string, second_string)
144+
return false unless first_string && second_string
145+
return false unless first_string.bytesize == second_string.bytesize
146+
147+
result = 0
148+
first_string.bytes.zip(second_string.bytes) { |x, y| result |= x ^ y }
149+
result.zero?
126150
end
127151

128152
def token_valid?(token_data, url)
@@ -135,8 +159,8 @@ def token_valid?(token_data, url)
135159

136160
##
137161
# Extract feed token from URL query parameters
138-
# @param url [String] the full URL with query parameters
139-
# @return [String, nil] feed token if found, nil otherwise
162+
# @param url [String] full URL with query parameters
163+
# @return [String, nil] feed token if found
140164
def extract_feed_token_from_url(url)
141165
URI.parse(url).then { |uri| URI.decode_www_form(uri.query || '').to_h['token'] }
142166
rescue StandardError
@@ -145,8 +169,8 @@ def extract_feed_token_from_url(url)
145169

146170
##
147171
# Check if a feed URL is allowed for the given feed token
148-
# @param feed_token [String] the feed token
149-
# @param url [String] the URL to check
172+
# @param feed_token [String] feed token
173+
# @param url [String] URL to check
150174
# @return [Boolean] true if URL is allowed
151175
def feed_url_allowed?(feed_token, url)
152176
account = validate_feed_token(feed_token, url)
@@ -157,13 +181,16 @@ def feed_url_allowed?(feed_token, url)
157181

158182
##
159183
# Extract token from request (Authorization header only)
160-
# @param request [Roda::Request] the request object
161-
# @return [String, nil] token if found, nil otherwise
184+
# @param request [Roda::Request] request object
185+
# @return [String, nil] token if found
162186
def extract_token(request)
163187
auth_header = request.env['HTTP_AUTHORIZATION']
164188
return unless auth_header&.start_with?('Bearer ')
165189

166-
auth_header.delete_prefix('Bearer ')
190+
token = auth_header.delete_prefix('Bearer ')
191+
return nil if token.empty? || token.length > 1024
192+
193+
token
167194
end
168195

169196
##
@@ -173,16 +200,10 @@ def accounts
173200
load_accounts
174201
end
175202

176-
##
177-
# Reload accounts from config (useful for development)
178-
def reload_accounts!
179-
accounts
180-
end
181-
182203
##
183204
# Get account by username
184-
# @param username [String] the username to find
185-
# @return [Hash, nil] account data if found, nil otherwise
205+
# @param username [String] username to find
206+
# @return [Hash, nil] account data if found
186207
def get_account_by_username(username)
187208
return nil unless username
188209

@@ -207,7 +228,7 @@ def load_accounts
207228

208229
##
209230
# Get the secret key for HMAC signing
210-
# @return [String, nil] secret key if configured, nil otherwise
231+
# @return [String, nil] secret key if configured
211232
def secret_key
212233
ENV.fetch('HTML2RSS_SECRET_KEY')
213234
end
@@ -251,14 +272,47 @@ def sanitize_xml(text)
251272
##
252273
# Validate URL format and scheme using Html2rss::Url.for_channel
253274
# @param url [String] URL to validate
254-
# @return [Boolean] true if URL is valid and allowed, false otherwise
275+
# @return [Boolean] true if URL is valid and allowed
255276
def valid_url?(url)
256-
return false unless url.is_a?(String) && !url.empty? && url.length <= 2048
277+
return false unless basic_url_valid?(url)
257278

258-
!Html2rss::Url.for_channel(url).nil?
279+
validate_url_with_html2rss(url)
259280
rescue StandardError
260281
false
261282
end
283+
284+
##
285+
# Basic URL format validation
286+
# @param url [String] URL to validate
287+
# @return [Boolean] true if basic format is valid
288+
def basic_url_valid?(url)
289+
url.is_a?(String) && !url.empty? && url.length <= 2048 && url.match?(%r{\Ahttps?://.+})
290+
end
291+
292+
##
293+
# Validate URL using Html2rss if available, otherwise basic validation
294+
# @param url [String] URL to validate
295+
# @return [Boolean] true if URL is valid
296+
def validate_url_with_html2rss(url)
297+
if defined?(Html2rss::Url) && Html2rss::Url.respond_to?(:for_channel)
298+
!Html2rss::Url.for_channel(url).nil?
299+
else
300+
# Fallback to basic URL validation for tests
301+
URI.parse(url).is_a?(URI::HTTP) || URI.parse(url).is_a?(URI::HTTPS)
302+
end
303+
end
304+
305+
##
306+
# Validate username format and length
307+
# @param username [String] username to validate
308+
# @return [Boolean] true if username is valid
309+
def valid_username?(username)
310+
return false unless username.is_a?(String)
311+
return false if username.empty? || username.length > 100
312+
return false unless username.match?(/\A[a-zA-Z0-9_-]+\z/)
313+
314+
true
315+
end
262316
end
263317
end
264318
end

config.ru

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,17 @@ if ENV.key?('SENTRY_DSN')
1111
Sentry.init do |config|
1212
config.dsn = ENV.fetch('SENTRY_DSN')
1313

14-
# Set traces_sample_rate to 1.0 to capture 100%
15-
# of transactions for tracing.
16-
# We recommend adjusting this value in production.
1714
config.traces_sample_rate = 1.0
18-
# or
19-
# config.traces_sampler = lambda do |_context|
20-
# true
21-
# end
22-
# Set profiles_sample_rate to profile 100%
23-
# of sampled transactions.
24-
# We recommend adjusting this value in production.
2515
config.profiles_sample_rate = 1.0
2616
end
2717

2818
use Sentry::Rack::CaptureExceptions
2919
end
3020

21+
require 'rack/attack'
22+
require_relative 'config/rack_attack'
23+
use Rack::Attack
24+
3125
dev = ENV.fetch('RACK_ENV', nil) == 'development'
3226
requires = Dir['app/**/*.rb']
3327

config/rack_attack.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require 'rack/attack'
4+
5+
# In-memory store (resets on restart)
6+
# Note: In production, consider using Redis for persistent rate limiting
7+
Rack::Attack.cache.store = {}
8+
9+
# Whitelist health checks and internal IPs
10+
Rack::Attack.safelist('health-check') do |req|
11+
req.path.start_with?('/health', '/status')
12+
end
13+
14+
# Whitelist localhost in development
15+
Rack::Attack.safelist('localhost') do |req|
16+
%w[127.0.0.1 ::1].include?(req.ip) if ENV['RACK_ENV'] == 'development'
17+
end
18+
19+
# Rate limiting by IP
20+
Rack::Attack.throttle('requests per IP', limit: 100, period: 60, &:ip)
21+
22+
# Rate limiting for API endpoints
23+
Rack::Attack.throttle('api requests per IP', limit: 200, period: 60) do |req|
24+
req.ip if req.path.start_with?('/api/')
25+
end
26+
27+
# Rate limiting for feed generation (more restrictive)
28+
Rack::Attack.throttle('feed generation per IP', limit: 10, period: 60) do |req|
29+
req.ip if req.path.include?('/feeds/')
30+
end
31+
32+
# Block suspicious patterns
33+
Rack::Attack.blocklist('block bad user agents') do |req|
34+
req.user_agent&.match?(/bot|crawler|spider/i) && !req.user_agent&.match?(/googlebot|bingbot/i)
35+
end
36+
37+
# Custom responses with proper headers
38+
Rack::Attack.throttled_response = lambda do |_env|
39+
retry_after = 60
40+
[
41+
429,
42+
{
43+
'Content-Type' => 'application/xml',
44+
'Retry-After' => retry_after.to_s,
45+
'X-RateLimit-Limit' => '100',
46+
'X-RateLimit-Remaining' => '0',
47+
'X-RateLimit-Reset' => (Time.now + retry_after).to_i.to_s
48+
},
49+
['<rss><channel><title>Rate Limited</title><description>Too many requests. ' \
50+
'Please try again later.</description></channel></rss>']
51+
]
52+
end
53+
54+
# Track blocked requests for monitoring
55+
Rack::Attack.blocklisted_response = lambda do |_env|
56+
[
57+
403,
58+
{ 'Content-Type' => 'application/xml' },
59+
['<rss><channel><title>Access Denied</title><description>Request blocked by ' \
60+
'security policy.</description></channel></rss>']
61+
]
62+
end

0 commit comments

Comments
 (0)