99require_relative 'local_config'
1010
1111module 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{\A https?://.+} )
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
264318end
0 commit comments