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.
- checksums.yaml +4 -4
- data/README.md +36 -3
- data/assets/flexdoc.standalone.js +52 -52
- data/lib/prauga/flexdoc/config.rb +2 -2
- data/lib/prauga/flexdoc/host.rb +17 -5
- data/lib/prauga/flexdoc/host_execution.rb +462 -0
- data/lib/prauga/flexdoc/rack_app.rb +137 -1
- data/lib/prauga/flexdoc/rails.rb +4 -3
- data/lib/prauga/flexdoc/version.rb +1 -1
- data/lib/prauga/flexdoc.rb +3 -2
- metadata +17 -2
|
@@ -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
|
-
#
|
|
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]
|
|
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",
|
data/lib/prauga/flexdoc/host.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
54
|
-
endpoint:
|
|
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
|
-
|
|
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
|