authproto 0.3.0.beta1
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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- metadata +200 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
|
|
9
|
+
module AuthProto
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# AES-GCM envelopes and context-specific key derivation for stored OAuth secrets.
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
module Encryption
|
|
16
|
+
|
|
17
|
+
# Base class for encryption and key-configuration failures.
|
|
18
|
+
class Error < AuthProto::Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when a required key derivation context is missing.
|
|
21
|
+
class ConfigurationError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when plaintext inputs are invalid or encryption fails.
|
|
24
|
+
class EncryptionError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when an encrypted envelope cannot be decoded, authenticated or decrypted.
|
|
27
|
+
class DecryptionError < Error; end
|
|
28
|
+
|
|
29
|
+
#
|
|
30
|
+
# HKDF implementation based on RFC 5869
|
|
31
|
+
#
|
|
32
|
+
|
|
33
|
+
module HKDF
|
|
34
|
+
|
|
35
|
+
# Derives key bytes with HKDF-SHA256 (RFC 5869).
|
|
36
|
+
#
|
|
37
|
+
# @param secret [#to_s] input key material
|
|
38
|
+
# @param salt [String] extraction salt; an empty string uses 32 zero bytes
|
|
39
|
+
# @param info [String] application/context information for expansion
|
|
40
|
+
# @param length [Integer] number of output bytes to derive
|
|
41
|
+
# @return [String] derived binary key material
|
|
42
|
+
#
|
|
43
|
+
def self.derive(secret, salt:, info:, length:)
|
|
44
|
+
# 1. extract
|
|
45
|
+
prk = OpenSSL::HMAC.digest(
|
|
46
|
+
OpenSSL::Digest.new("SHA256"),
|
|
47
|
+
salt.empty? ? "\x00" * 32 : salt,
|
|
48
|
+
secret.to_s
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 2. expand
|
|
52
|
+
n = (length.to_f / 32).ceil
|
|
53
|
+
t = [""]
|
|
54
|
+
output = ""
|
|
55
|
+
1.upto(n) do |i|
|
|
56
|
+
t[i] = OpenSSL::HMAC.digest(
|
|
57
|
+
OpenSSL::Digest.new("SHA256"),
|
|
58
|
+
prk,
|
|
59
|
+
t[i - 1] + info + [i].pack("C")
|
|
60
|
+
)
|
|
61
|
+
output += t[i]
|
|
62
|
+
end
|
|
63
|
+
output[0, length]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#
|
|
68
|
+
# Core encryption service - used internally by serializers
|
|
69
|
+
#
|
|
70
|
+
|
|
71
|
+
class Service
|
|
72
|
+
|
|
73
|
+
# OpenSSL cipher name used for authenticated encryption.
|
|
74
|
+
# @return [String]
|
|
75
|
+
CIPHER = "aes-256-gcm"
|
|
76
|
+
|
|
77
|
+
# Version stored in encrypted field envelopes.
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
VERSION = 1
|
|
80
|
+
|
|
81
|
+
# Creates an encryption service backed by {KeyProvider}.
|
|
82
|
+
# @raise [ArgumentError] if the configured master key is not valid Base64
|
|
83
|
+
#
|
|
84
|
+
def initialize
|
|
85
|
+
@key_provider = KeyProvider.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Encrypts a string representation of data using AES-256-GCM and a context-derived key.
|
|
89
|
+
#
|
|
90
|
+
# The context is also authenticated; decryption must use the same context.
|
|
91
|
+
#
|
|
92
|
+
# @param data [#to_s] non-`nil` plaintext value, converted to a string
|
|
93
|
+
# @param context [String] nonempty context used for key derivation and authentication
|
|
94
|
+
# @return [Hash{Symbol => Integer, String}] envelope with `:version` (Integer) and Base64 `:iv`, `:data`, `:tag` (String)
|
|
95
|
+
# @raise [EncryptionError] if inputs are invalid or encryption fails
|
|
96
|
+
#
|
|
97
|
+
def encrypt(data, context:)
|
|
98
|
+
validate_encryption_inputs(data, context)
|
|
99
|
+
|
|
100
|
+
iv = SecureRandom.random_bytes(12)
|
|
101
|
+
|
|
102
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
103
|
+
cipher.encrypt
|
|
104
|
+
cipher.key = @key_provider.key_for_context(context)
|
|
105
|
+
cipher.iv = iv
|
|
106
|
+
cipher.auth_data = context.to_s
|
|
107
|
+
|
|
108
|
+
encrypted = cipher.update(data.to_s) + cipher.final
|
|
109
|
+
auth_tag = cipher.auth_tag
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
version: VERSION,
|
|
113
|
+
iv: Base64.strict_encode64(iv),
|
|
114
|
+
data: Base64.strict_encode64(encrypted),
|
|
115
|
+
tag: Base64.strict_encode64(auth_tag)
|
|
116
|
+
}
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
raise EncryptionError, "Encryption failed: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Authenticates and decrypts an envelope returned by {#encrypt}.
|
|
122
|
+
#
|
|
123
|
+
# @param encrypted [Hash{Symbol => Integer, String}] symbol-keyed `:version`, `:iv`, `:data`, `:tag` envelope
|
|
124
|
+
# @param context [String] same nonempty context used for encryption
|
|
125
|
+
# @return [String] plaintext bytes
|
|
126
|
+
# @raise [DecryptionError] if the context, key, version, encoding or authentication tag is invalid
|
|
127
|
+
#
|
|
128
|
+
def decrypt(encrypted, context:)
|
|
129
|
+
validate_decryption_inputs(encrypted, context)
|
|
130
|
+
validate_encrypted_data(encrypted)
|
|
131
|
+
|
|
132
|
+
iv = Base64.strict_decode64(encrypted[:iv])
|
|
133
|
+
data = Base64.strict_decode64(encrypted[:data])
|
|
134
|
+
auth_tag = Base64.strict_decode64(encrypted[:tag])
|
|
135
|
+
|
|
136
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
137
|
+
cipher.decrypt
|
|
138
|
+
cipher.key = @key_provider.key_for_context(context)
|
|
139
|
+
cipher.iv = iv
|
|
140
|
+
cipher.auth_tag = auth_tag
|
|
141
|
+
cipher.auth_data = context.to_s
|
|
142
|
+
|
|
143
|
+
cipher.update(data) + cipher.final
|
|
144
|
+
rescue ArgumentError => e
|
|
145
|
+
raise DecryptionError, "Invalid encrypted data format: #{e.message}"
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
raise DecryptionError, "Decryption failed: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def validate_encryption_inputs(data, context)
|
|
154
|
+
raise EncryptionError, "Data cannot be nil" if data.nil?
|
|
155
|
+
raise EncryptionError, "Context cannot be nil" if context.nil?
|
|
156
|
+
raise EncryptionError, "Context must be a string" unless context.is_a?(String)
|
|
157
|
+
raise EncryptionError, "Context cannot be empty" if context.empty?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_decryption_inputs(encrypted, context)
|
|
161
|
+
raise DecryptionError, "Encrypted data cannot be nil" if encrypted.nil?
|
|
162
|
+
raise DecryptionError, "Context cannot be nil" if context.nil?
|
|
163
|
+
raise DecryptionError, "Context must be a string" unless context.is_a?(String)
|
|
164
|
+
raise DecryptionError, "Context cannot be empty" if context.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_encrypted_data(encrypted)
|
|
168
|
+
raise DecryptionError, "Invalid encrypted data format" unless encrypted.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
unless encrypted[:version] == VERSION
|
|
171
|
+
raise DecryptionError, "Unsupported encryption version: #{encrypted[:version]}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
%i[iv data tag].each do |field|
|
|
175
|
+
raise DecryptionError, "Missing required field: #{field}" unless encrypted[field].is_a?(String)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
#
|
|
181
|
+
# Handles key management and derivation
|
|
182
|
+
#
|
|
183
|
+
|
|
184
|
+
class KeyProvider
|
|
185
|
+
|
|
186
|
+
# Protects creation of the process fallback master key.
|
|
187
|
+
# @return [Mutex]
|
|
188
|
+
EPHEMERAL_KEY_MUTEX = Mutex.new
|
|
189
|
+
|
|
190
|
+
# Returns a randomly generated master key shared by this class within the process.
|
|
191
|
+
#
|
|
192
|
+
# The key is cached under a mutex and is lost when the process exits.
|
|
193
|
+
#
|
|
194
|
+
# @return [String] 32 bytes of binary key material
|
|
195
|
+
#
|
|
196
|
+
def self.ephemeral_master_key
|
|
197
|
+
EPHEMERAL_KEY_MUTEX.synchronize do
|
|
198
|
+
@ephemeral_master_key ||= SecureRandom.random_bytes(32)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Loads the master key from `ATPROTO_MASTER_KEY` or uses the process fallback.
|
|
203
|
+
#
|
|
204
|
+
# Set the environment variable to a Base64-encoded master secret, typically
|
|
205
|
+
# 32 random bytes. Context-specific AES-256 keys are derived from that secret.
|
|
206
|
+
# Without it, a warning is emitted and {KeyProvider.ephemeral_master_key} is used;
|
|
207
|
+
# data encrypted with that fallback cannot be recovered after a restart.
|
|
208
|
+
#
|
|
209
|
+
# @raise [ArgumentError] if `ATPROTO_MASTER_KEY` is not valid Base64
|
|
210
|
+
#
|
|
211
|
+
def initialize
|
|
212
|
+
@master_key = load_master_key
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Derives a 32-byte key specific to an encryption context.
|
|
216
|
+
#
|
|
217
|
+
# @param context [String] context label, such as `data.access_token`
|
|
218
|
+
# @return [String] context-specific binary key
|
|
219
|
+
# @raise [ConfigurationError] if context is `nil`
|
|
220
|
+
#
|
|
221
|
+
def key_for_context(context)
|
|
222
|
+
raise ConfigurationError, "Context is required" if context.nil?
|
|
223
|
+
|
|
224
|
+
HKDF.derive(
|
|
225
|
+
@master_key,
|
|
226
|
+
salt: salt_for_context(context),
|
|
227
|
+
info: "atproto-#{context}",
|
|
228
|
+
length: 32
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def load_master_key
|
|
236
|
+
# Try environment variable first
|
|
237
|
+
key = ENV.fetch("ATPROTO_MASTER_KEY", nil)
|
|
238
|
+
return Base64.strict_decode64(key) if key
|
|
239
|
+
|
|
240
|
+
# Generate and store a random key if not configured
|
|
241
|
+
warn "WARNING: Using randomly generated encryption key - tokens will not persist across restarts"
|
|
242
|
+
self.class.ephemeral_master_key
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def salt_for_context(context)
|
|
246
|
+
OpenSSL::Digest.digest("SHA256", "atproto-salt-#{context}")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
|
|
5
|
+
# Base class for library errors, except standalone localhost callback errors.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
#
|
|
9
|
+
# Base class for AT Protocol OAuth errors
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
class OAuthError < Error
|
|
13
|
+
|
|
14
|
+
# OAuth error code associated with the failure.
|
|
15
|
+
# @return [String]
|
|
16
|
+
attr_reader :error_code
|
|
17
|
+
|
|
18
|
+
# Creates an OAuth failure with a human-readable message and protocol code.
|
|
19
|
+
#
|
|
20
|
+
# @param message [String] error description
|
|
21
|
+
# @param error_code [String] OAuth error code
|
|
22
|
+
#
|
|
23
|
+
def initialize(message, error_code)
|
|
24
|
+
@error_code = error_code
|
|
25
|
+
# @type-ignore
|
|
26
|
+
super(message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#
|
|
31
|
+
# Error raised when client metadata is invalid or cannot be retrieved.
|
|
32
|
+
# This can occur during client metadata fetching, parsing, or validation.
|
|
33
|
+
#
|
|
34
|
+
# @example Handling client metadata errors
|
|
35
|
+
# begin
|
|
36
|
+
# client = AuthProto::Client.new(
|
|
37
|
+
# client_id: "https://myapp.com/metadata.json",
|
|
38
|
+
# redirect_uri: "https://myapp.com/callback"
|
|
39
|
+
# )
|
|
40
|
+
# rescue AuthProto::InvalidClientMetadata => e
|
|
41
|
+
# puts "Failed to validate client metadata: #{e.message}"
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
|
|
45
|
+
class InvalidClientMetadata < OAuthError
|
|
46
|
+
|
|
47
|
+
# Creates a metadata error with the `invalid_client_metadata` code.
|
|
48
|
+
# @param message [String] explanation of the validation or retrieval failure
|
|
49
|
+
def initialize(message)
|
|
50
|
+
super(message, "invalid_client_metadata")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# Error raised when authorization server metadata is invalid or cannot be retrieved.
|
|
56
|
+
# This includes issues with server metadata fetching, parsing, or validation against
|
|
57
|
+
# the AT Protocol OAuth requirements.
|
|
58
|
+
#
|
|
59
|
+
# @example Handling authorization server errors
|
|
60
|
+
# begin
|
|
61
|
+
# server = AuthProto::ServerMetadata::AuthorizationServer.from_issuer("https://auth.example.com")
|
|
62
|
+
# rescue AuthProto::InvalidAuthorizationServer => e
|
|
63
|
+
# puts "Failed to validate authorization server: #{e.message}"
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
|
|
67
|
+
class InvalidAuthorizationServer < OAuthError
|
|
68
|
+
|
|
69
|
+
# Creates a metadata error with the `invalid_authorization_server` code.
|
|
70
|
+
# @param message [String] explanation of the validation or retrieval failure
|
|
71
|
+
def initialize(message)
|
|
72
|
+
super(message, "invalid_authorization_server")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'resolv'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
require_relative 'errors'
|
|
10
|
+
|
|
11
|
+
module AuthProto
|
|
12
|
+
|
|
13
|
+
#
|
|
14
|
+
# A secure HTTP client for making OAuth-related requests.
|
|
15
|
+
# Implements protections against SSRF attacks and enforces security headers.
|
|
16
|
+
#
|
|
17
|
+
|
|
18
|
+
class HTTPClient
|
|
19
|
+
|
|
20
|
+
# IPv4/IPv6 destinations rejected by the secure transport.
|
|
21
|
+
# @return [Array<IPAddr>]
|
|
22
|
+
FORBIDDEN_IP_RANGES = [
|
|
23
|
+
IPAddr.new("0.0.0.0/8"), # Current network
|
|
24
|
+
IPAddr.new("10.0.0.0/8"), # Private network
|
|
25
|
+
IPAddr.new("127.0.0.0/8"), # Loopback
|
|
26
|
+
IPAddr.new("169.254.0.0/16"), # Link-local
|
|
27
|
+
IPAddr.new("172.16.0.0/12"), # Private network
|
|
28
|
+
IPAddr.new("192.168.0.0/16"), # Private network
|
|
29
|
+
IPAddr.new("::/128"), # Unspecified IPv6 address
|
|
30
|
+
IPAddr.new("::1/128"), # IPv6 loopback
|
|
31
|
+
IPAddr.new("fc00::/7"), # Unique local address
|
|
32
|
+
IPAddr.new("fe80::/10") # Link-local address
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# URL schemes accepted by the secure transport; HTTPS only.
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
ALLOWED_SCHEMES = ["https"].freeze
|
|
38
|
+
|
|
39
|
+
# Default open/read timeout in seconds.
|
|
40
|
+
# @return [Integer]
|
|
41
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
42
|
+
|
|
43
|
+
# Maximum redirects followed for a single request.
|
|
44
|
+
# @return [Integer]
|
|
45
|
+
MAX_REDIRECTS = 5
|
|
46
|
+
|
|
47
|
+
# Maximum accepted response body size in bytes.
|
|
48
|
+
# @return [Integer]
|
|
49
|
+
MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
50
|
+
|
|
51
|
+
# Error raised when a request is blocked due to SSRF protection
|
|
52
|
+
class SSRFError < Error; end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# Error raised when an HTTP request fails
|
|
56
|
+
#
|
|
57
|
+
|
|
58
|
+
class HTTPError < Error
|
|
59
|
+
|
|
60
|
+
# Response associated with the failure, when retained by the transport.
|
|
61
|
+
# @return [Net::HTTPResponse, nil]
|
|
62
|
+
attr_reader :response
|
|
63
|
+
|
|
64
|
+
# Creates an HTTP transport error with an optional response.
|
|
65
|
+
#
|
|
66
|
+
# @param message [String] failure description
|
|
67
|
+
# @param response [Net::HTTPResponse, nil] response associated with the error
|
|
68
|
+
#
|
|
69
|
+
def initialize(message, response)
|
|
70
|
+
@response = response
|
|
71
|
+
super(message)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Value object for redirect request context.
|
|
76
|
+
#
|
|
77
|
+
# Fields are `original_uri` (URI::HTTP), `method` (Symbol), `response`
|
|
78
|
+
# (Net::HTTPResponse), `headers` (a string-keyed header hash), `redirect_count`
|
|
79
|
+
# (Integer), and `body` (String, Hash or nil). Inherits construction and immutable
|
|
80
|
+
# member access from `Data`.
|
|
81
|
+
#
|
|
82
|
+
# @see Data
|
|
83
|
+
#
|
|
84
|
+
RedirectHandlerOptions = Data.define(:original_uri, :method, :response, :headers, :redirect_count, :body)
|
|
85
|
+
|
|
86
|
+
# Creates an HTTP transport with timeouts and certificate verification settings.
|
|
87
|
+
#
|
|
88
|
+
# @param timeout [Numeric] open and read timeout, each in seconds
|
|
89
|
+
# @param verify_ssl [Boolean] whether HTTPS peer certificates must be verified
|
|
90
|
+
#
|
|
91
|
+
def initialize(timeout: DEFAULT_TIMEOUT, verify_ssl: true)
|
|
92
|
+
@timeout = timeout
|
|
93
|
+
@verify_ssl = verify_ssl
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Sends an HTTP GET request with destination validation and redirect handling.
|
|
97
|
+
#
|
|
98
|
+
# Uses HTTPS and rejects private/loopback destinations by default. Subclasses may
|
|
99
|
+
# relax these rules. Follows at most {MAX_REDIRECTS} redirects and checks size
|
|
100
|
+
# against {MAX_RESPONSE_SIZE} using `Content-Length`, or body bytes when absent.
|
|
101
|
+
# Non-2xx status codes are returned for the caller to interpret; they do not
|
|
102
|
+
# themselves raise an exception.
|
|
103
|
+
#
|
|
104
|
+
# @param url [String] absolute HTTPS request URL without a fragment
|
|
105
|
+
# @param headers [Hash{String => String}] additional request headers; can override the default `Cache-Control` value
|
|
106
|
+
# @param follow_redirects [Boolean] follow redirects, or return the initial response when false
|
|
107
|
+
# @return [Hash{Symbol => Integer, Hash{String => String}, String, nil}] `:status` (Integer), lowercase `:headers` (Hash), and `:body` (String or nil)
|
|
108
|
+
# @raise [SSRFError] if initial scheme, host, fragment or resolved address is rejected
|
|
109
|
+
# @raise [HTTPError] if transport, TLS, redirect validation or response size checks fail
|
|
110
|
+
# @raise [URI::InvalidURIError] if the initial URL cannot be parsed
|
|
111
|
+
#
|
|
112
|
+
def get(url, headers = {}, follow_redirects: true)
|
|
113
|
+
uri = validate_uri(url)
|
|
114
|
+
validate_ip(uri)
|
|
115
|
+
|
|
116
|
+
response = make_request(uri, headers, follow_redirects: follow_redirects)
|
|
117
|
+
validate_response(response)
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
status: response.code.to_i,
|
|
121
|
+
headers: response.each_header.to_h,
|
|
122
|
+
body: response.body
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Sends an HTTP POST request with destination validation and redirect handling.
|
|
127
|
+
#
|
|
128
|
+
# Uses HTTPS and rejects private/loopback destinations by default. Subclasses may
|
|
129
|
+
# relax these rules. Follows at most {MAX_REDIRECTS} redirects and checks size
|
|
130
|
+
# against {MAX_RESPONSE_SIZE} using `Content-Length`, or body bytes when absent.
|
|
131
|
+
# Non-2xx status codes are returned for the caller to interpret; they do not
|
|
132
|
+
# themselves raise an exception.
|
|
133
|
+
#
|
|
134
|
+
# @param url [String] absolute HTTPS request URL without a fragment
|
|
135
|
+
# @param body [String, Hash, nil] raw bytes, form fields encoded with `URI.encode_www_form`, or no body
|
|
136
|
+
# @param headers [Hash{String => String}] additional request headers; can override the default `Cache-Control` value
|
|
137
|
+
# @return [Hash{Symbol => Integer, Hash{String => String}, String, nil}] `:status` (Integer), lowercase `:headers` (Hash), and `:body` (String or nil)
|
|
138
|
+
# @raise [SSRFError] if initial scheme, host, fragment or resolved address is rejected
|
|
139
|
+
# @raise [HTTPError] if transport, TLS, redirect validation or response size checks fail
|
|
140
|
+
# @raise [URI::InvalidURIError] if the initial URL cannot be parsed
|
|
141
|
+
#
|
|
142
|
+
def post(url, body: nil, headers: {})
|
|
143
|
+
uri = validate_uri(url)
|
|
144
|
+
validate_ip(uri)
|
|
145
|
+
|
|
146
|
+
response = make_post_request(uri, body, headers)
|
|
147
|
+
validate_response(response)
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
status: response.code.to_i,
|
|
151
|
+
headers: response.each_header.to_h,
|
|
152
|
+
body: response.body
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def validate_uri(url)
|
|
160
|
+
uri = URI(url)
|
|
161
|
+
unless self.class::ALLOWED_SCHEMES.include?(uri.scheme)
|
|
162
|
+
raise SSRFError, "URL scheme must be one of: #{self.class::ALLOWED_SCHEMES.join(", ")}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Extract and validate host before any network activity
|
|
166
|
+
host = uri.host.to_s.strip
|
|
167
|
+
raise SSRFError, "URL must include host" if host.empty?
|
|
168
|
+
raise SSRFError, "URL must not include fragment" if uri.fragment
|
|
169
|
+
|
|
170
|
+
uri
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_ip(uri)
|
|
174
|
+
# URI#hostname removes brackets from IPv6 literals.
|
|
175
|
+
begin
|
|
176
|
+
parsed_ip = IPAddr.new(uri.hostname)
|
|
177
|
+
rescue IPAddr::InvalidAddressError
|
|
178
|
+
parsed_ip = nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if parsed_ip
|
|
182
|
+
if forbidden_ip?(parsed_ip)
|
|
183
|
+
raise SSRFError, "Request to forbidden IP address"
|
|
184
|
+
else
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Also check resolved IPs for hostnames
|
|
190
|
+
begin
|
|
191
|
+
ips = Resolv::DNS.new.getaddresses(uri.hostname)
|
|
192
|
+
raise SSRFError, "Could not resolve hostname" if ips.empty?
|
|
193
|
+
|
|
194
|
+
ips.each do |x|
|
|
195
|
+
ip_addr = IPAddr.new(x.to_s)
|
|
196
|
+
raise SSRFError, "Request to forbidden IP address" if forbidden_ip?(ip_addr)
|
|
197
|
+
rescue IPAddr::InvalidAddressError
|
|
198
|
+
next
|
|
199
|
+
end
|
|
200
|
+
rescue Resolv::ResolvError
|
|
201
|
+
raise SSRFError, "Could not resolve hostname"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def forbidden_ip?(ip)
|
|
206
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
207
|
+
FORBIDDEN_IP_RANGES.any? { |range| range.include?(ip) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def make_request(uri, headers = {}, redirect_count = 0, follow_redirects: true)
|
|
211
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
212
|
+
configure_http_client(http, ssl: uri.scheme == "https")
|
|
213
|
+
|
|
214
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
215
|
+
add_security_headers(request, headers)
|
|
216
|
+
response = http.request(request)
|
|
217
|
+
return response unless follow_redirects
|
|
218
|
+
|
|
219
|
+
handle_redirect(
|
|
220
|
+
original_uri: uri,
|
|
221
|
+
response: response,
|
|
222
|
+
headers: headers,
|
|
223
|
+
redirect_count: redirect_count
|
|
224
|
+
)
|
|
225
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
226
|
+
raise HTTPError.new("Request timeout: #{e.message}", nil)
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
raise HTTPError.new("Request failed: #{e.message}", nil)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def make_post_request(uri, body, headers = {}, redirect_count = 0)
|
|
232
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
233
|
+
configure_http_client(http, ssl: uri.scheme == "https")
|
|
234
|
+
|
|
235
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
236
|
+
add_security_headers(request, headers)
|
|
237
|
+
request.body = body.is_a?(Hash) ? URI.encode_www_form(body) : body if body
|
|
238
|
+
|
|
239
|
+
response = http.request(request)
|
|
240
|
+
handle_redirect(
|
|
241
|
+
original_uri: uri,
|
|
242
|
+
body: body,
|
|
243
|
+
method: :post,
|
|
244
|
+
response: response,
|
|
245
|
+
headers: headers,
|
|
246
|
+
redirect_count: redirect_count
|
|
247
|
+
)
|
|
248
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
249
|
+
raise HTTPError.new("Request timeout: #{e.message}", nil)
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
raise HTTPError.new("Request failed: #{e.message}", nil)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def configure_http_client(http, ssl: true)
|
|
255
|
+
http.use_ssl = ssl
|
|
256
|
+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
257
|
+
http.read_timeout = @timeout
|
|
258
|
+
http.open_timeout = @timeout
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def add_security_headers(request, headers)
|
|
262
|
+
# Prevent caching of sensitive data
|
|
263
|
+
request["Cache-Control"] = "no-store"
|
|
264
|
+
|
|
265
|
+
# Add user-provided headers
|
|
266
|
+
headers.each { |k, v| request[k] = v }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Handle HTTP redirects
|
|
270
|
+
# kwargs can include:
|
|
271
|
+
# - original_uri: URI of the original request
|
|
272
|
+
# - method: HTTP method of the original request (:get or :post)
|
|
273
|
+
# - response: Net::HTTPResponse object
|
|
274
|
+
# - headers: Hash of headers from the original request
|
|
275
|
+
# - redirect_count: Number of redirects so far
|
|
276
|
+
# - body: Request body for POST requests
|
|
277
|
+
#
|
|
278
|
+
def handle_redirect(**kwargs)
|
|
279
|
+
response = kwargs[:response]
|
|
280
|
+
redirect_count = kwargs[:redirect_count]
|
|
281
|
+
|
|
282
|
+
return response unless response.is_a?(Net::HTTPRedirection)
|
|
283
|
+
raise HTTPError.new("Too many redirects", response) if redirect_count >= MAX_REDIRECTS
|
|
284
|
+
|
|
285
|
+
location = URI(response["location"])
|
|
286
|
+
location = kwargs[:original_uri] + location if location.relative?
|
|
287
|
+
|
|
288
|
+
validate_uri(location.to_s)
|
|
289
|
+
validate_ip(location)
|
|
290
|
+
|
|
291
|
+
# Increment redirect count for the next request
|
|
292
|
+
redirect_count += 1
|
|
293
|
+
|
|
294
|
+
# Recursive call to handle the next redirect
|
|
295
|
+
if kwargs[:method] == :post
|
|
296
|
+
make_post_request(location, kwargs[:body], kwargs[:headers], redirect_count)
|
|
297
|
+
else
|
|
298
|
+
make_request(location, kwargs[:headers], redirect_count)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def validate_response(response)
|
|
303
|
+
# check_success_status(response)
|
|
304
|
+
check_content_length(response)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def check_success_status(response)
|
|
308
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
309
|
+
|
|
310
|
+
raise HTTPError.new("HTTP request failed: #{response.code} #{response.message}", response)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def check_content_length(response)
|
|
314
|
+
content_length = response["content-length"]&.to_i || response.body&.bytesize || 0
|
|
315
|
+
return unless content_length > MAX_RESPONSE_SIZE
|
|
316
|
+
|
|
317
|
+
raise HTTPError.new("Response too large: #{content_length} bytes", response)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'http_client'
|
|
4
|
+
|
|
5
|
+
module AuthProto
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Development transport for mixed local HTTP and remote HTTPS requests.
|
|
9
|
+
# Allows private/loopback destinations; HTTPS certificate verification is kept.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
class InsecureHTTPClient < HTTPClient
|
|
13
|
+
|
|
14
|
+
# HTTP and HTTPS schemes accepted for development requests.
|
|
15
|
+
# @return [Array<String>]
|
|
16
|
+
ALLOWED_SCHEMES = %w[http https].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_ip(uri)
|
|
21
|
+
# Deliberately bypass IP restrictions for local development environments.
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|