prauga-flexdoc 0.4.4 → 0.4.5

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.
@@ -23,7 +23,7 @@ module Prauga
23
23
  # @!attribute [r] try_it_api_client_persistence_key
24
24
  # Optional persistence key, or `false` to disable.
25
25
  # @!attribute [r] try_it_host_execution
26
- # Emits host-execution protocol metadata; execution is not implemented by this adapter.
26
+ # Enables host-execution protocol metadata when a real native executor is attached to the host.
27
27
  Config = Data.define(
28
28
  :path,
29
29
  :spec_url,
@@ -47,7 +47,7 @@ module Prauga
47
47
  # @param try_it_default_server [String, nil] optional default server URL for Try It
48
48
  # @param try_it_credentials [String, nil] optional fetch credentials mode
49
49
  # @param try_it_api_client_persistence_key [String, false, nil] optional persistence key
50
- # @param try_it_host_execution [Boolean] emit host-execution protocol metadata; execution is not implemented by this adapter
50
+ # @param try_it_host_execution [Boolean] expose host execution when the host has a native executor
51
51
  def initialize(
52
52
  path: "/docs",
53
53
  spec_url: "/openapi.json",
@@ -14,20 +14,32 @@ module Prauga
14
14
  # @return [Config] validated renderer and route configuration for this host
15
15
  # @!attribute [r] fingerprint
16
16
  # @return [String] stable renderer-asset fingerprint used for cache-busting URLs
17
- attr_reader :config, :fingerprint
17
+ # @!attribute [r] host_execution
18
+ # @return [HostExecution, nil] optional native API-host executor
19
+ attr_reader :config, :fingerprint, :host_execution
18
20
 
19
21
  # Create a host and load its canonical renderer assets.
20
22
  #
21
23
  # @param config [Config] renderer and route settings
22
24
  # @param assets_dir [String, nil] optional directory overriding bundled renderer assets
23
- def initialize(config = Config.new, assets_dir: nil)
25
+ # @param host_execution [HostExecution, nil] optional native API-host executor
26
+ def initialize(config = Config.new, assets_dir: nil, host_execution: nil)
24
27
  @config = config
28
+ @host_execution = host_execution
25
29
  root = assets_dir || File.expand_path("../../../assets", __dir__)
26
30
  @javascript = File.binread(File.join(root, "flexdoc.standalone.js"))
27
31
  @css = File.binread(File.join(root, "flexdoc.standalone.css"))
28
32
  @fingerprint = Digest::SHA256.hexdigest(@javascript + "\0" + @css)[0, 16]
29
33
  end
30
34
 
35
+ def execute_path
36
+ "#{config.path}/__flexdoc/execute"
37
+ end
38
+
39
+ def execution_available?
40
+ config.try_it_host_execution && !host_execution.nil?
41
+ end
42
+
31
43
  # Match a request path and return the docs shell, renderer asset, or 404 response.
32
44
  #
33
45
  # @param path [String] request path
@@ -50,9 +62,9 @@ module Prauga
50
62
  try_it[:apiClientPersistenceKey] = config.try_it_api_client_persistence_key unless config.try_it_api_client_persistence_key.nil?
51
63
  if config.try_it_host_execution
52
64
  try_it[:hostExecution] = {
53
- available: false,
54
- endpoint: "#{config.path}/__flexdoc/execute",
55
- capabilities: []
65
+ available: execution_available?,
66
+ endpoint: execute_path,
67
+ capabilities: host_execution&.capabilities || []
56
68
  }
57
69
  end
58
70
 
@@ -0,0 +1,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "ipaddr"
5
+ require "json"
6
+ require "net/http"
7
+ require "securerandom"
8
+ require "socket"
9
+ require "timeout"
10
+ require "uri"
11
+
12
+ module Prauga
13
+ module FlexDoc
14
+ HostExecutionFile = Data.define(:filename, :content_type, :data)
15
+ HostExecutionResult = Data.define(:status, :body)
16
+
17
+ # Framework-neutral implementation of FlexDoc's existing API-host execution envelope.
18
+ class HostExecution
19
+ MAX_EXECUTION_REQUEST_BYTES = 32 * 1024 * 1024
20
+ MAX_EXECUTION_RESPONSE_BYTES = 10 * 1024 * 1024
21
+ DEFAULT_TIMEOUT_MS = 30_000
22
+ MIN_TIMEOUT_MS = 100
23
+ MAX_TIMEOUT_MS = 120_000
24
+ MAX_REDIRECTS = 5
25
+ UNSAFE_HEADERS = %w[
26
+ connection keep-alive proxy-authenticate proxy-authorization te trailer
27
+ transfer-encoding upgrade host content-length set-cookie origin referer
28
+ ].freeze
29
+ METADATA_HOSTS = %w[169.254.169.254 metadata.google.internal metadata.google].freeze
30
+ LINK_LOCAL_V4 = IPAddr.new("169.254.0.0/16")
31
+ LINK_LOCAL_V6 = IPAddr.new("fe80::/10")
32
+ SUPPORTED_METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS].freeze
33
+
34
+ attr_reader :allowed_origins
35
+
36
+ def initialize(allowed_origins:)
37
+ normalized = Array(allowed_origins).filter_map do |raw|
38
+ value = raw.to_s.strip
39
+ next if value.empty?
40
+
41
+ uri = parse_http_uri(value)
42
+ unless uri.userinfo.nil? && [nil, "", "/"].include?(uri.path) && uri.query.nil? && uri.fragment.nil?
43
+ raise ArgumentError,
44
+ "host execution allowed origins cannot contain credentials, paths, queries, or fragments: #{value}"
45
+ end
46
+ origin_of(uri)
47
+ rescue URI::InvalidURIError
48
+ raise ArgumentError, "host execution allowed origin #{value.inspect} must be an absolute HTTP(S) origin"
49
+ end
50
+ raise ArgumentError, "FlexDoc host execution requires at least one exact allowed origin" if normalized.empty?
51
+
52
+ @allowed_origins = normalized.uniq.freeze
53
+ end
54
+
55
+ def capabilities
56
+ []
57
+ end
58
+
59
+ def handle(marker:, envelope:, files: {})
60
+ return HostExecutionResult.new(status: 403, body: { "error" => "Missing X-FlexDoc-Execute header." }) unless marker == "1"
61
+
62
+ HostExecutionResult.new(status: 200, body: execute(envelope, files))
63
+ rescue ExecutionError => error
64
+ HostExecutionResult.new(status: error.status, body: { "error" => error.message })
65
+ end
66
+
67
+ private
68
+
69
+ class ExecutionError < StandardError
70
+ attr_reader :status
71
+
72
+ def initialize(status, message)
73
+ @status = status
74
+ super(message)
75
+ end
76
+ end
77
+
78
+ def bad_request(message) = raise(ExecutionError.new(400, message))
79
+ def forbidden(message) = raise(ExecutionError.new(403, message))
80
+ def upstream(message) = raise(ExecutionError.new(502, message))
81
+
82
+ def execute(envelope, files)
83
+ root = envelope.is_a?(Hash) ? envelope : bad_request("Host execution body must be a JSON object.")
84
+ bad_request("Session cookie jars are not implemented by the Ruby host executor.") if root["cookieJar"] == "session"
85
+ certificate_id = root["certificateId"].to_s
86
+ bad_request("Client certificates are not implemented by the Ruby host executor.") unless certificate_id.strip.empty?
87
+
88
+ draft = root["request"]
89
+ bad_request("Host execution body requires a canonical request draft.") unless draft.is_a?(Hash)
90
+
91
+ raw_url = string_value(draft["url"])
92
+ bad_request("Host execution requires an absolute request URL.") if raw_url.strip.empty?
93
+ target = parse_http_uri(raw_url)
94
+ forbidden("Host execution URLs cannot contain embedded credentials.") unless target.userinfo.nil?
95
+ append_query!(target, entries(draft["query"]))
96
+
97
+ method = string_value(draft["method"]).strip.upcase
98
+ method = "GET" if method.empty?
99
+ bad_request("Unsupported host execution HTTP method: #{method}") unless SUPPORTED_METHODS.include?(method)
100
+
101
+ headers = sanitize_headers(entries(draft["headers"]))
102
+ apply_header_auth!(draft["auth"], headers)
103
+ mode = infer_body_mode(draft)
104
+ body, content_type = prepare_body(draft, root, files, mode)
105
+ headers.delete("content-type") if mode == "formdata"
106
+ headers["content-type"] ||= [content_type] if content_type
107
+
108
+ timeout_ms = integer_value(root["timeoutMs"], DEFAULT_TIMEOUT_MS).clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
109
+ execute_with_redirects(method, target, headers, body, timeout_ms, draft["auth"])
110
+ rescue URI::InvalidURIError
111
+ bad_request("Host execution requires an absolute HTTP(S) request URL.")
112
+ end
113
+
114
+ def execute_with_redirects(method, current, headers, body, timeout_ms, auth)
115
+ Timeout.timeout(timeout_ms / 1000.0) do
116
+ (0..MAX_REDIRECTS).each do |redirect_count|
117
+ request_uri = current.dup
118
+ apply_query_auth!(auth, request_uri)
119
+ validated_ip = assert_allowed!(request_uri)
120
+
121
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ response = perform_request(method, request_uri, headers, body, timeout_ms, validated_ip)
123
+ status = response[:status]
124
+
125
+ if status.between?(300, 399) && response[:location]
126
+ forbidden("Host execution exceeded the redirect safety limit.") if redirect_count == MAX_REDIRECTS
127
+ next_uri = request_uri.merge(response[:location])
128
+ forbidden("Host execution does not follow cross-origin redirects.") unless origin_of(next_uri) == origin_of(request_uri)
129
+ assert_allowed!(next_uri)
130
+ if status == 303
131
+ method = "GET"
132
+ body = "".b
133
+ headers = headers.reject { |name, _| name == "content-type" }
134
+ end
135
+ current = next_uri
136
+ next
137
+ end
138
+
139
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
140
+ return {
141
+ "status" => status,
142
+ "statusText" => response[:message],
143
+ "headers" => response[:headers],
144
+ "body" => response[:body].dup.force_encoding(Encoding::UTF_8).scrub,
145
+ "responseTime" => elapsed
146
+ }
147
+ end
148
+ end
149
+ forbidden("Host execution exceeded the redirect safety limit.")
150
+ rescue Timeout::Error
151
+ upstream("Host execution request timed out after #{timeout_ms} ms.")
152
+ rescue ExecutionError
153
+ raise
154
+ rescue StandardError => error
155
+ upstream("Host execution request failed: #{error.message}")
156
+ end
157
+
158
+ def perform_request(method, uri, headers, body, timeout_ms, validated_ip)
159
+ request = Net::HTTPGenericRequest.new(method, true, method != "HEAD", uri.request_uri, {})
160
+ headers.each do |name, values|
161
+ Array(values).each { |value| request.add_field(name, value) }
162
+ end
163
+ request.body = body unless body.empty? && %w[GET HEAD].include?(method)
164
+
165
+ response_body = "".b
166
+ response_headers = nil
167
+ status = nil
168
+ message = nil
169
+ location = nil
170
+ seconds = timeout_ms / 1000.0
171
+
172
+ # Disable environment proxies and pin the connection to the address validated above.
173
+ # Net::HTTP keeps +uri.host+ as the HTTP/TLS identity while +ipaddr+ controls the
174
+ # actual TCP destination, closing the DNS-preflight/connection-time lookup gap.
175
+ http = Net::HTTP.new(uri.host, uri.port, nil)
176
+ http.ipaddr = validated_ip
177
+ http.use_ssl = uri.scheme == "https"
178
+ http.open_timeout = seconds
179
+ http.read_timeout = seconds
180
+ http.write_timeout = seconds if http.respond_to?(:write_timeout=)
181
+ http.start do |session|
182
+ session.request(request) do |response|
183
+ status = response.code.to_i
184
+ message = response.message.to_s
185
+ location = response["location"]
186
+ response_headers = response.to_hash.flat_map do |name, values|
187
+ Array(values).map { |value| [name, value] }
188
+ end
189
+ response.read_body do |chunk|
190
+ if response_body.bytesize + chunk.bytesize > MAX_EXECUTION_RESPONSE_BYTES
191
+ upstream("Host execution response exceeded the 10 MiB safety limit.")
192
+ end
193
+ response_body << chunk.b
194
+ end
195
+ end
196
+ end
197
+
198
+ { status:, message:, headers: response_headers || [], body: response_body, location: }
199
+ end
200
+
201
+ def assert_allowed!(uri)
202
+ forbidden("Host execution only allows HTTP(S) URLs.") unless uri.is_a?(URI::HTTP) && uri.host
203
+ forbidden("Host execution URLs cannot contain embedded credentials.") unless uri.userinfo.nil?
204
+ origin = origin_of(uri)
205
+ forbidden("Origin #{origin} is not allowed for host execution.") unless allowed_origins.include?(origin)
206
+
207
+ host = uri.host.to_s.downcase.delete_prefix("[").delete_suffix("]")
208
+ forbidden("Host execution blocks link-local and cloud metadata endpoints.") if METADATA_HOSTS.include?(host)
209
+ if ip_literal?(host)
210
+ forbidden("Host execution blocks link-local and cloud metadata endpoints.") if metadata_ip?(host)
211
+ return host
212
+ end
213
+
214
+ addresses = Addrinfo.getaddrinfo(host, uri.port, nil, :STREAM)
215
+ upstream("Host execution could not resolve target hostname.") if addresses.empty?
216
+ if addresses.any? { |address| metadata_ip?(address.ip_address) }
217
+ forbidden("Host execution blocks DNS resolutions to link-local and cloud metadata endpoints.")
218
+ end
219
+
220
+ addresses.first.ip_address
221
+ rescue SocketError
222
+ upstream("Host execution could not resolve target hostname.")
223
+ end
224
+
225
+ def parse_http_uri(raw)
226
+ uri = URI.parse(raw)
227
+ raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) && uri.host
228
+
229
+ uri
230
+ end
231
+
232
+ def origin_of(uri)
233
+ default_port = uri.scheme == "https" ? 443 : 80
234
+ host = uri.host.to_s
235
+ host = "[#{host}]" if host.include?(":") && !host.start_with?("[")
236
+ port = uri.port == default_port ? "" : ":#{uri.port}"
237
+ "#{uri.scheme.downcase}://#{host.downcase}#{port}"
238
+ end
239
+
240
+ def append_query!(uri, values)
241
+ encoded = values.filter_map do |entry|
242
+ next unless entry_enabled?(entry)
243
+ key = string_value(entry["key"])
244
+ next if key.strip.empty?
245
+
246
+ URI.encode_www_form([[key, string_value(entry["value"])]])
247
+ end
248
+ return if encoded.empty?
249
+
250
+ uri.query = [uri.query, *encoded].compact.reject(&:empty?).join("&")
251
+ end
252
+
253
+ def sanitize_headers(values)
254
+ values.each_with_object({}) do |entry, result|
255
+ next unless entry_enabled?(entry)
256
+ name = string_value(entry["key"]).strip
257
+ next if name.empty?
258
+ normalized = name.downcase
259
+ next if UNSAFE_HEADERS.include?(normalized) || normalized.start_with?("proxy-", "sec-")
260
+ bad_request("Invalid host execution request header: #{name}") unless name.match?(/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/)
261
+ value = string_value(entry["value"])
262
+ bad_request("Invalid host execution request header: #{name}") if value.include?("\r") || value.include?("\n")
263
+ (result[normalized] ||= []) << value
264
+ end
265
+ end
266
+
267
+ def set_header!(headers, raw_name, raw_value)
268
+ name = raw_name.to_s.strip
269
+ bad_request("Invalid host execution request header: #{raw_name}") if name.empty? || !name.match?(/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/)
270
+ normalized = name.downcase
271
+ if UNSAFE_HEADERS.include?(normalized) || normalized.start_with?("proxy-", "sec-")
272
+ bad_request("Unsafe host execution request header: #{name}")
273
+ end
274
+ value = raw_value.to_s
275
+ bad_request("Invalid host execution request header: #{name}") if value.include?("\r") || value.include?("\n")
276
+ headers[normalized] = [value]
277
+ end
278
+
279
+ def apply_header_auth!(raw, headers)
280
+ auth = raw.is_a?(Hash) ? raw : {}
281
+ case string_value(auth["type"])
282
+ when "", "none", "inherit"
283
+ nil
284
+ when "bearer"
285
+ token = string_value(auth["token"])
286
+ set_header!(headers, "authorization", "Bearer #{token}") unless token.empty?
287
+ when "oauth2"
288
+ token = string_value(auth["accessToken"])
289
+ set_header!(headers, "authorization", "Bearer #{token}") unless token.empty?
290
+ when "basic"
291
+ credential = "#{string_value(auth["username"])}:#{string_value(auth["password"])}"
292
+ set_header!(headers, "authorization", "Basic #{Base64.strict_encode64(credential)}")
293
+ when "apiKey"
294
+ key = string_value(auth["key"]).strip
295
+ bad_request("API key authentication requires a key name.") if key.empty?
296
+ location = string_value(auth["in"])
297
+ location = "header" if location.empty?
298
+ case location
299
+ when "header"
300
+ set_header!(headers, key, string_value(auth["value"]))
301
+ when "query"
302
+ nil
303
+ when "cookie"
304
+ bad_request("Cookie authentication is not implemented by the Ruby host executor.")
305
+ else
306
+ bad_request("Unsupported API key location: #{location}")
307
+ end
308
+ else
309
+ bad_request("Authentication type #{string_value(auth["type"])} is not implemented by the Ruby host executor.")
310
+ end
311
+ end
312
+
313
+ def apply_query_auth!(raw, uri)
314
+ auth = raw.is_a?(Hash) ? raw : {}
315
+ return unless string_value(auth["type"]) == "apiKey" && string_value(auth["in"]) == "query"
316
+
317
+ key = string_value(auth["key"])
318
+ bad_request("API key authentication requires a key name.") if key.strip.empty?
319
+ encoded = URI.encode_www_form([[key, string_value(auth["value"])]])
320
+ uri.query = [uri.query, encoded].compact.reject(&:empty?).join("&")
321
+ end
322
+
323
+ def prepare_body(draft, envelope, files, mode)
324
+ explicit_type = string_value(draft["contentType"])
325
+ case mode
326
+ when "none"
327
+ ["".b, nil]
328
+ when "raw"
329
+ [string_value(draft["body"]).b, non_empty(explicit_type)]
330
+ when "json"
331
+ [string_value(draft["body"]).b, explicit_type.empty? ? "application/json" : explicit_type]
332
+ when "binary"
333
+ encoded = string_value(envelope["bodyBase64"])
334
+ bad_request("Binary host execution requires bodyBase64.") if encoded.empty?
335
+ data = Base64.strict_decode64(encoded)
336
+ nested = draft["binary"].is_a?(Hash) ? string_value(draft["binary"]["contentType"]) : ""
337
+ content_type = explicit_type.empty? ? (nested.empty? ? "application/octet-stream" : nested) : explicit_type
338
+ [data.b, content_type]
339
+ when "urlencoded"
340
+ pairs = entries(draft["urlencoded"]).filter_map do |entry|
341
+ next unless entry_enabled?(entry)
342
+ key = string_value(entry["key"])
343
+ next if key.strip.empty?
344
+ [key, string_value(entry["value"])]
345
+ end
346
+ [URI.encode_www_form(pairs).b, explicit_type.empty? ? "application/x-www-form-urlencoded" : explicit_type]
347
+ when "graphql"
348
+ graph = draft["graphql"]
349
+ bad_request("GraphQL body must be an object.") unless graph.is_a?(Hash)
350
+ variables_text = string_value(graph["variables"])
351
+ variables = variables_text.strip.empty? ? {} : JSON.parse(variables_text)
352
+ [JSON.generate({ "query" => string_value(graph["query"]), "variables" => variables }).b,
353
+ explicit_type.empty? ? "application/json" : explicit_type]
354
+ when "formdata"
355
+ build_multipart(draft, files)
356
+ else
357
+ bad_request("Body mode #{mode} is not implemented by the Ruby host executor.")
358
+ end
359
+ rescue ArgumentError
360
+ bad_request("Binary host execution bodyBase64 is invalid.") if mode == "binary"
361
+ raise
362
+ rescue JSON::ParserError
363
+ bad_request("GraphQL variables must be valid JSON.")
364
+ end
365
+
366
+ def build_multipart(draft, files)
367
+ boundary = "----flexdoc-ruby-#{SecureRandom.hex(12)}"
368
+ output = "".b
369
+ entries(draft["formData"]).each_with_index do |entry, index|
370
+ next unless entry_enabled?(entry)
371
+ key = string_value(entry["key"])
372
+ next if key.strip.empty?
373
+
374
+ if string_value(entry["type"]) == "file"
375
+ file = files[index]
376
+ bad_request("Host execution multipart file formData[#{index}] is missing.") unless file
377
+ filename = non_empty(file.filename.to_s) || non_empty(string_value(entry["fileName"])) || "upload.bin"
378
+ content_type = non_empty(file.content_type.to_s) || non_empty(string_value(entry["contentType"])) || "application/octet-stream"
379
+ bad_request("Host execution multipart Content-Type is invalid.") if content_type.include?("\r") || content_type.include?("\n")
380
+ output << "--#{boundary}\r\n"
381
+ output << "Content-Disposition: form-data; name=\"#{quote_multipart(key)}\"; filename=\"#{quote_multipart(filename)}\"\r\n"
382
+ output << "Content-Type: #{content_type}\r\n\r\n"
383
+ output << file.data.b << "\r\n"
384
+ else
385
+ output << "--#{boundary}\r\n"
386
+ output << "Content-Disposition: form-data; name=\"#{quote_multipart(key)}\"\r\n\r\n"
387
+ output << string_value(entry["value"]).b << "\r\n"
388
+ end
389
+ end
390
+ output << "--#{boundary}--\r\n"
391
+ [output, "multipart/form-data; boundary=#{boundary}"]
392
+ end
393
+
394
+ def infer_body_mode(draft)
395
+ explicit = string_value(draft["bodyMode"])
396
+ return explicit unless explicit.strip.empty?
397
+ binary = draft["binary"]
398
+ return "binary" if binary.is_a?(Hash) && !string_value(binary["fileName"]).empty?
399
+ return "formdata" unless entries(draft["formData"]).empty?
400
+ return "urlencoded" unless entries(draft["urlencoded"]).empty?
401
+ graph = draft["graphql"]
402
+ if graph.is_a?(Hash) && (!string_value(graph["query"]).empty? || !string_value(graph["variables"]).empty?)
403
+ return "graphql"
404
+ end
405
+ body = string_value(draft["body"])
406
+ return "none" if body.empty?
407
+
408
+ string_value(draft["contentType"]).downcase.include?("json") ? "json" : "raw"
409
+ end
410
+
411
+ def entries(raw)
412
+ raw.is_a?(Array) ? raw.select { |entry| entry.is_a?(Hash) } : []
413
+ end
414
+
415
+ def entry_enabled?(entry)
416
+ entry["enabled"] != false
417
+ end
418
+
419
+ def string_value(raw)
420
+ case raw
421
+ when nil then ""
422
+ when String then raw
423
+ when true, false, Numeric then raw.to_s
424
+ else JSON.generate(raw)
425
+ end
426
+ end
427
+
428
+ def integer_value(raw, fallback)
429
+ Integer(raw || fallback)
430
+ rescue ArgumentError, TypeError, RangeError
431
+ fallback
432
+ end
433
+
434
+ def non_empty(value)
435
+ value.nil? || value.empty? ? nil : value
436
+ end
437
+
438
+ def quote_multipart(value)
439
+ value.to_s.delete("\r\n").gsub("\\") { "\\\\" }.gsub('"') { '\\"' }
440
+ end
441
+
442
+ def ip_literal?(host)
443
+ IPAddr.new(host)
444
+ true
445
+ rescue IPAddr::InvalidAddressError
446
+ false
447
+ end
448
+
449
+ def metadata_ip?(value)
450
+ ip = IPAddr.new(value)
451
+ return true if LINK_LOCAL_V4.include?(ip) || LINK_LOCAL_V6.include?(ip)
452
+ if ip.ipv6? && (ip.to_i >> 32) == 0xFFFF
453
+ mapped = IPAddr.new(ip.to_i & 0xFFFF_FFFF, Socket::AF_INET)
454
+ return true if LINK_LOCAL_V4.include?(mapped)
455
+ end
456
+ false
457
+ rescue IPAddr::InvalidAddressError
458
+ false
459
+ end
460
+ end
461
+ end
462
+ end
@@ -1,11 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Prauga
4
6
  module FlexDoc
5
7
  # Rack application that serves FlexDoc routes from a {Host}.
6
8
  class RackApp
7
9
  # @param host [Host] host backing the Rack application
8
- def initialize(host = Host.new)
10
+ # @param host_execution_protected [Boolean] explicit acknowledgement that the docs/execute surface is protected by application auth/middleware
11
+ def initialize(host = Host.new, host_execution_protected: false)
12
+ if host.execution_available? && !host_execution_protected
13
+ raise ArgumentError,
14
+ "FlexDoc Rack host execution requires host_execution_protected: true after configuring application auth/middleware; the origin allowlist is not authentication."
15
+ end
16
+
9
17
  @host = host
10
18
  end
11
19
 
@@ -15,8 +23,136 @@ module Prauga
15
23
  # @return [Array]
16
24
  def call(env)
17
25
  path = "#{env.fetch("SCRIPT_NAME", "")}#{env.fetch("PATH_INFO", "")}"
26
+ if path == @host.execute_path
27
+ return not_found unless env.fetch("REQUEST_METHOD", "GET") == "POST" && @host.execution_available?
28
+
29
+ return execute(env)
30
+ end
31
+
18
32
  @host.response_for_path(path).rack
19
33
  end
34
+
35
+ private
36
+
37
+ def execute(env)
38
+ marker = env["HTTP_X_FLEXDOC_EXECUTE"]
39
+ return execution_error(403, "Missing X-FlexDoc-Execute header.") unless marker == "1"
40
+
41
+ raw = read_bounded(env)
42
+ content_type = env.fetch("CONTENT_TYPE", "")
43
+ envelope, files = if content_type.split(";", 2).first.to_s.strip.casecmp("application/json").zero?
44
+ [parse_json_object(raw, "Host execution body must be valid UTF-8 JSON object."), {}]
45
+ elsif content_type.split(";", 2).first.to_s.strip.casecmp("multipart/form-data").zero?
46
+ parse_multipart(content_type, raw)
47
+ else
48
+ return execution_error(400, "Host execution requires application/json or multipart/form-data.")
49
+ end
50
+ result = @host.host_execution.handle(
51
+ marker: marker,
52
+ envelope:,
53
+ files:
54
+ )
55
+ execution_json(result.status, result.body)
56
+ rescue EnvelopeError => error
57
+ execution_error(400, error.message)
58
+ end
59
+
60
+ def read_bounded(env)
61
+ declared = env["CONTENT_LENGTH"].to_i
62
+ if declared > HostExecution::MAX_EXECUTION_REQUEST_BYTES
63
+ raise EnvelopeError, "Host execution request exceeded the 32 MiB safety limit."
64
+ end
65
+ input = env.fetch("rack.input")
66
+ raw = input.read(HostExecution::MAX_EXECUTION_REQUEST_BYTES + 1).to_s.b
67
+ if raw.bytesize > HostExecution::MAX_EXECUTION_REQUEST_BYTES
68
+ raise EnvelopeError, "Host execution request exceeded the 32 MiB safety limit."
69
+ end
70
+ raw
71
+ end
72
+
73
+ def parse_json_object(raw, message)
74
+ text = raw.dup.force_encoding(Encoding::UTF_8)
75
+ raise EnvelopeError, message unless text.valid_encoding?
76
+
77
+ value = JSON.parse(text)
78
+ raise EnvelopeError, message unless value.is_a?(Hash)
79
+
80
+ value
81
+ rescue JSON::ParserError
82
+ raise EnvelopeError, message
83
+ end
84
+
85
+ def parse_multipart(content_type, raw)
86
+ boundary = content_type[/boundary=(?:"([^"]+)"|([^;]+))/, 1] || content_type[/boundary=(?:"([^"]+)"|([^;]+))/, 2]
87
+ raise EnvelopeError, "Host execution multipart body is invalid." if boundary.to_s.strip.empty?
88
+
89
+ descriptor = nil
90
+ files = {}
91
+ delimiter = "--#{boundary}".b
92
+ raw.split(delimiter).each do |segment|
93
+ next if segment.empty?
94
+ segment = segment.sub(/\A\r\n/n, "")
95
+ break if segment.start_with?("--")
96
+ segment = segment.sub(/\r\n\z/n, "")
97
+ header_blob, data = segment.split("\r\n\r\n".b, 2)
98
+ raise EnvelopeError, "Host execution multipart body is invalid." unless data
99
+
100
+ headers = header_blob.split("\r\n".b).each_with_object({}) do |line, result|
101
+ name, value = line.split(":", 2)
102
+ next unless value
103
+ result[name.to_s.downcase] = value.to_s.strip
104
+ end
105
+ disposition = headers.fetch("content-disposition", "")
106
+ name = disposition[/\bname="([^"]*)"/, 1].to_s
107
+ filename = disposition[/\bfilename="([^"]*)"/, 1].to_s
108
+
109
+ if name == "descriptor"
110
+ raise EnvelopeError, "Host execution multipart request contains multiple descriptors." if descriptor
111
+ descriptor = data
112
+ next
113
+ end
114
+
115
+ match = name.match(/\AformData\[(\d+)\]\z/)
116
+ next unless match
117
+ index = match[1].to_i
118
+ if files.key?(index)
119
+ raise EnvelopeError, "Host execution multipart request contains duplicate formData[#{index}] parts."
120
+ end
121
+ files[index] = HostExecutionFile.new(
122
+ filename:,
123
+ content_type: headers.fetch("content-type", ""),
124
+ data: data.b
125
+ )
126
+ end
127
+
128
+ raise EnvelopeError, "Host execution multipart request requires a descriptor." unless descriptor
129
+ [parse_json_object(descriptor, "Host execution multipart descriptor must be valid UTF-8 JSON object."), files]
130
+ rescue ArgumentError
131
+ raise EnvelopeError, "Host execution multipart body is invalid."
132
+ end
133
+
134
+ def execution_error(status, message)
135
+ execution_json(status, { "error" => message })
136
+ end
137
+
138
+ def execution_json(status, payload)
139
+ body = JSON.generate(payload)
140
+ [
141
+ status,
142
+ {
143
+ "content-type" => "application/json; charset=utf-8",
144
+ "content-length" => body.bytesize.to_s,
145
+ "cache-control" => "no-store"
146
+ },
147
+ [body]
148
+ ]
149
+ end
150
+
151
+ def not_found
152
+ Response.new(status: 404, content_type: "text/plain; charset=utf-8", body: "Not Found", cache_control: nil).rack
153
+ end
154
+
155
+ class EnvelopeError < StandardError; end
20
156
  end
21
157
  end
22
158
  end