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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. 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