|
2 | 2 |
|
3 | 3 | require 'base64' |
4 | 4 | require 'json' |
5 | | -require 'zlib' |
6 | 5 | require 'openssl' |
| 6 | +require 'zlib' |
7 | 7 | require_relative 'url_validator' |
8 | 8 |
|
9 | 9 | module Html2rss |
10 | 10 | module Web |
11 | | - # Token configuration constants |
12 | 11 | DEFAULT_EXPIRY = 315_360_000 # 10 years in seconds |
13 | 12 | HMAC_ALGORITHM = 'SHA256' |
| 13 | + REQUIRED_TOKEN_KEYS = %i[p s].freeze |
| 14 | + COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze |
14 | 15 |
|
15 | | - # Compressed feed token with HMAC validation |
16 | 16 | FeedToken = Data.define(:username, :url, :expires_at, :signature) do |
17 | | - # @param username [String] |
18 | | - # @param url [String] |
19 | | - # @param secret_key [String] |
20 | | - # @param expires_in [Integer] seconds (default: 10 years) |
21 | | - # @return [FeedToken, nil] |
22 | | - def self.create_with_validation(username:, url:, secret_key:, expires_in: Html2rss::Web::DEFAULT_EXPIRY) |
23 | | - return nil unless valid_inputs?(username, url, secret_key) |
| 17 | + def self.create_with_validation(username:, url:, secret_key:, expires_in: DEFAULT_EXPIRY) |
| 18 | + return unless valid_inputs?(username, url, secret_key) |
24 | 19 |
|
25 | 20 | expires_at = Time.now.to_i + expires_in.to_i |
26 | | - payload = create_payload(username, url, expires_at) |
| 21 | + payload = build_payload(username, url, expires_at) |
27 | 22 | signature = generate_signature(secret_key, payload) |
28 | 23 |
|
29 | 24 | new(username: username, url: url, expires_at: expires_at, signature: signature) |
30 | 25 | end |
31 | 26 |
|
32 | | - # @param encoded_token [String] |
33 | | - # @return [FeedToken, nil] |
34 | | - def self.decode(encoded_token) |
35 | | - return nil unless encoded_token |
| 27 | + def self.decode(encoded_token) # rubocop:disable Metrics/MethodLength |
| 28 | + return unless encoded_token |
36 | 29 |
|
37 | 30 | token_data = parse_token_data(encoded_token) |
38 | | - return nil unless valid_token_data?(token_data) |
| 31 | + return unless valid_token_data?(token_data) |
39 | 32 |
|
40 | | - create_from_token_data(token_data) |
| 33 | + payload = token_data[:p] |
| 34 | + new( |
| 35 | + username: payload[:u], |
| 36 | + url: payload[:l], |
| 37 | + expires_at: payload[:e], |
| 38 | + signature: token_data[:s] |
| 39 | + ) |
41 | 40 | rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError |
42 | 41 | nil |
43 | 42 | end |
44 | 43 |
|
45 | | - # @return [String] compressed base64-encoded token |
46 | | - def encode |
47 | | - token_data = build_token_data |
48 | | - compressed_data = Zlib::Deflate.deflate(token_data.to_json) |
49 | | - Base64.urlsafe_encode64(compressed_data) |
50 | | - end |
51 | | - |
52 | | - # @return [Boolean] |
53 | | - def expired? |
54 | | - Time.now.to_i > expires_at |
55 | | - end |
56 | | - |
57 | | - # @param url [String] |
58 | | - # @return [Boolean] |
59 | | - def valid_for_url?(url) |
60 | | - self.url == url |
61 | | - end |
62 | | - |
63 | | - # @param encoded_token [String] |
64 | | - # @param expected_url [String] |
65 | | - # @param secret_key [String] |
66 | | - # @return [FeedToken, nil] |
67 | 44 | def self.validate_and_decode(encoded_token, expected_url, secret_key) |
68 | 45 | token = decode(encoded_token) |
69 | | - return nil unless token |
70 | | - |
71 | | - return nil unless token.valid_signature?(secret_key) |
72 | | - return nil unless token.valid_for_url?(expected_url) |
73 | | - return nil if token.expired? |
| 46 | + return unless token |
| 47 | + return unless token.valid_signature?(secret_key) |
| 48 | + return unless token.valid_for_url?(expected_url) |
| 49 | + return if token.expired? |
74 | 50 |
|
75 | 51 | token |
76 | 52 | end |
77 | 53 |
|
78 | | - # @return [Hash] payload for HMAC verification |
79 | | - def payload_for_signature |
80 | | - { |
81 | | - username: username, |
82 | | - url: url, |
83 | | - expires_at: expires_at |
84 | | - } |
| 54 | + def encode |
| 55 | + compressed = Zlib::Deflate.deflate(build_token_data.to_json) |
| 56 | + Base64.urlsafe_encode64(compressed) |
| 57 | + end |
| 58 | + |
| 59 | + def expired? |
| 60 | + Time.now.to_i > expires_at |
85 | 61 | end |
86 | 62 |
|
87 | | - # @param username [String] |
88 | | - # @param url [String] |
89 | | - # @param secret_key [String] |
90 | | - # @return [Boolean] |
91 | | - def self.valid_inputs?(username, url, secret_key) |
92 | | - valid_username?(username) && UrlValidator.valid_url?(url) && secret_key |
| 63 | + def valid_for_url?(candidate_url) |
| 64 | + url == candidate_url |
93 | 65 | end |
94 | 66 |
|
95 | | - # @param secret_key [String] |
96 | | - # @return [Boolean] |
97 | 67 | def valid_signature?(secret_key) |
98 | | - return false unless secret_key |
| 68 | + return false unless self.class.valid_secret_key?(secret_key) |
99 | 69 |
|
100 | 70 | expected_signature = self.class.generate_signature(secret_key, payload_for_signature) |
101 | 71 | secure_compare(signature, expected_signature) |
102 | 72 | end |
103 | 73 |
|
104 | 74 | private |
105 | 75 |
|
106 | | - # @param encoded_token [String] |
107 | | - # @return [Hash, nil] |
108 | | - def self.parse_token_data(encoded_token) |
109 | | - compressed_data = Base64.urlsafe_decode64(encoded_token) |
110 | | - json_data = Zlib::Inflate.inflate(compressed_data) |
111 | | - JSON.parse(json_data, symbolize_names: true) |
| 76 | + def payload_for_signature |
| 77 | + { username: username, url: url, expires_at: expires_at } |
112 | 78 | end |
113 | 79 |
|
114 | | - # @param token_data [Hash] |
115 | | - # @return [Boolean] |
116 | | - def self.valid_token_data?(token_data) |
117 | | - return false unless token_data[:p] && token_data[:s] |
118 | | - |
119 | | - payload = token_data[:p] |
120 | | - payload[:u] && payload[:l] && payload[:e] |
| 80 | + def build_token_data |
| 81 | + { p: { u: username, l: url, e: expires_at }, s: signature } |
121 | 82 | end |
122 | 83 |
|
123 | | - # @param token_data [Hash] |
124 | | - # @return [FeedToken] |
125 | | - def self.create_from_token_data(token_data) |
126 | | - payload = token_data[:p] |
127 | | - new( |
128 | | - username: payload[:u], |
129 | | - url: payload[:l], |
130 | | - expires_at: payload[:e], |
131 | | - signature: token_data[:s] |
132 | | - ) |
133 | | - end |
| 84 | + def secure_compare(first, second) |
| 85 | + return false unless first && second && first.bytesize == second.bytesize |
134 | 86 |
|
135 | | - # @return [Hash] |
136 | | - def build_token_data |
137 | | - compressed_payload = { |
138 | | - u: username, |
139 | | - l: url, |
140 | | - e: expires_at |
141 | | - } |
142 | | - |
143 | | - { |
144 | | - p: compressed_payload, |
145 | | - s: signature |
146 | | - } |
| 87 | + first.each_byte.zip(second.each_byte).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero? |
147 | 88 | end |
148 | 89 |
|
149 | | - # @param username [String] |
150 | | - # @param url [String] |
151 | | - # @param expires_at [Integer] |
152 | | - # @return [Hash] |
153 | | - def self.create_payload(username, url, expires_at) |
154 | | - { |
155 | | - username: username, |
156 | | - url: url, |
157 | | - expires_at: expires_at |
158 | | - } |
159 | | - end |
| 90 | + class << self |
| 91 | + def build_payload(username, url, expires_at) |
| 92 | + { username: username, url: url, expires_at: expires_at } |
| 93 | + end |
160 | 94 |
|
161 | | - # @param secret_key [String] |
162 | | - # @param payload [Hash] |
163 | | - # @return [String] |
164 | | - def self.generate_signature(secret_key, payload) |
165 | | - OpenSSL::HMAC.hexdigest(Html2rss::Web::HMAC_ALGORITHM, secret_key, payload.to_json) |
166 | | - end |
| 95 | + def generate_signature(secret_key, payload) |
| 96 | + data = payload.is_a?(String) ? payload : JSON.generate(payload) |
| 97 | + OpenSSL::HMAC.hexdigest(HMAC_ALGORITHM, secret_key, data) |
| 98 | + end |
167 | 99 |
|
168 | | - # @param username [String] |
169 | | - # @return [Boolean] |
170 | | - def self.valid_username?(username) |
171 | | - return false unless username.is_a?(String) |
172 | | - return false if username.empty? || username.length > 100 |
173 | | - return false unless username.match?(/\A[a-zA-Z0-9_-]+\z/) |
| 100 | + def parse_token_data(encoded_token) |
| 101 | + decoded = Base64.urlsafe_decode64(encoded_token) |
| 102 | + inflated = Zlib::Inflate.inflate(decoded) |
| 103 | + JSON.parse(inflated, symbolize_names: true) |
| 104 | + end |
174 | 105 |
|
175 | | - true |
176 | | - end |
| 106 | + def valid_token_data?(token_data) |
| 107 | + return false unless token_data.is_a?(Hash) |
177 | 108 |
|
178 | | - # @param url [String] |
179 | | - # @return [Boolean] |
180 | | - def self.valid_url?(url) |
181 | | - UrlValidator.valid_url?(url) |
182 | | - end |
| 109 | + payload = token_data[:p] |
| 110 | + signature = token_data[:s] |
| 111 | + payload.is_a?(Hash) && signature.is_a?(String) && !signature.empty? && |
| 112 | + COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] } |
| 113 | + end |
| 114 | + |
| 115 | + def valid_inputs?(username, url, secret_key) |
| 116 | + valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) |
| 117 | + end |
| 118 | + |
| 119 | + def valid_username?(username) |
| 120 | + username.is_a?(String) && !username.empty? && username.length <= 100 && username.match?(/\A[a-zA-Z0-9_-]+\z/) |
| 121 | + end |
183 | 122 |
|
184 | | - # Constant-time comparison to prevent timing attacks |
185 | | - # @param first_string [String] |
186 | | - # @param second_string [String] |
187 | | - # @return [Boolean] |
188 | | - def secure_compare(first_string, second_string) |
189 | | - return false unless first_string && second_string |
190 | | - return false unless first_string.bytesize == second_string.bytesize |
191 | | - |
192 | | - result = 0 |
193 | | - first_string.bytes.zip(second_string.bytes) { |x, y| result |= x ^ y } |
194 | | - result.zero? |
| 123 | + def valid_secret_key?(secret_key) |
| 124 | + secret_key.is_a?(String) && !secret_key.empty? |
| 125 | + end |
195 | 126 | end |
196 | 127 | end |
197 | 128 | end |
|
0 commit comments