forest_liana 9.19.0 → 9.20.0
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c37d0a61be7a37b80bab933cf4892cef1aceb25a5f8351d56c66dbd4d07e6c4d
|
|
4
|
+
data.tar.gz: d857a4ebb09833f6c99afb44d4daf2e06b396689331c6e28359fe78117e78727
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d8e83baf30e64e0c1a0fed567e62c7f1ed8b1763924a813967090616cf703e103b5f53609b0579d811bd286fd107aef17fa50ec14d92dac67ed8cc77cfc51b12
|
|
7
|
+
data.tar.gz: d2c6655ba0c37f0a917018e32ac3afdc0bbcbc3de9ebb82bce59cf2e4043a53145c7897184322a1c372ea9e51c223cc03c59714d312b728b7466044bd8a575a1
|
|
@@ -1,71 +1,126 @@
|
|
|
1
1
|
require 'httparty'
|
|
2
|
+
require 'uri'
|
|
2
3
|
|
|
3
4
|
module ForestLiana
|
|
4
5
|
class WorkflowExecutionsController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
# Never forwarded (request or response): hop-by-hop, Host, and body-framing headers.
|
|
7
|
+
# render json: re-serializes the body, so the upstream length/encoding no longer match — and
|
|
8
|
+
# forwarding accept-encoding would defeat Net::HTTP's transparent gzip decompression.
|
|
9
|
+
SKIPPED_HEADERS = %w[
|
|
10
|
+
connection keep-alive transfer-encoding upgrade te trailer
|
|
11
|
+
proxy-authenticate proxy-authorization host
|
|
12
|
+
content-length content-encoding accept-encoding
|
|
13
|
+
].freeze
|
|
14
|
+
UNSAFE_PATH_FRAGMENTS = ['..', '%2e', '%2E', '\\', "\0"].freeze
|
|
15
|
+
OPEN_TIMEOUT_IN_SECONDS = 2
|
|
16
|
+
REQUEST_TIMEOUT_IN_SECONDS = 120
|
|
7
17
|
UPSTREAM_ERRORS = [
|
|
8
18
|
HTTParty::Error,
|
|
9
19
|
SocketError,
|
|
10
20
|
Errno::ECONNREFUSED,
|
|
11
21
|
Net::OpenTimeout,
|
|
12
22
|
Net::ReadTimeout,
|
|
13
|
-
Timeout::Error
|
|
23
|
+
Timeout::Error,
|
|
24
|
+
OpenSSL::SSL::SSLError
|
|
14
25
|
].freeze
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def trigger
|
|
21
|
-
forward_to_executor(method: :post, suffix: '/trigger')
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
private
|
|
25
|
-
|
|
26
|
-
def forward_to_executor(method:, suffix:)
|
|
27
|
+
# Catch-all: forward any verb/sub-path verbatim to the executor, so a new executor route needs
|
|
28
|
+
# no change here.
|
|
29
|
+
def proxy
|
|
27
30
|
base = ForestLiana.workflow_executor_url
|
|
28
|
-
if base.blank?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return head(:not_found) if base.blank?
|
|
32
|
+
|
|
33
|
+
normalized_base = base.sub(%r{/+\z}, '')
|
|
34
|
+
path = safe_executor_path
|
|
35
|
+
return head(:not_found) if path.nil?
|
|
36
|
+
return head(:not_found) unless same_origin?(normalized_base, path)
|
|
32
37
|
|
|
33
|
-
url = "#{base.sub(%r{/+\z}, '')}/runs/#{params[:run_id]}#{suffix}"
|
|
34
38
|
response = HTTParty.send(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
headers:
|
|
39
|
+
request.request_method_symbol,
|
|
40
|
+
"#{normalized_base}#{path}",
|
|
41
|
+
headers: forwarded_request_headers,
|
|
38
42
|
query: forwarded_query,
|
|
39
|
-
body: forwarded_body
|
|
43
|
+
body: forwarded_body,
|
|
40
44
|
verify: Rails.env.production?,
|
|
41
|
-
|
|
45
|
+
# Don't follow redirects: a 3xx Location to an off-origin host would bypass the origin guard.
|
|
46
|
+
follow_redirects: false,
|
|
47
|
+
open_timeout: OPEN_TIMEOUT_IN_SECONDS,
|
|
48
|
+
timeout: REQUEST_TIMEOUT_IN_SECONDS
|
|
42
49
|
)
|
|
43
50
|
|
|
51
|
+
forward_response_headers(response)
|
|
44
52
|
render json: response.parsed_response, status: response.code
|
|
45
53
|
rescue *UPSTREAM_ERRORS => e
|
|
46
54
|
Rails.logger.error("[ForestLiana] workflow executor proxy error: #{e.class}: #{e.message}")
|
|
47
55
|
render json: { error: 'workflow_executor_unreachable' }, status: :service_unavailable
|
|
48
56
|
end
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# First-pass rejection of escape attempts; the authoritative origin check is same_origin?.
|
|
61
|
+
def safe_executor_path
|
|
62
|
+
wildcard = params[:path].to_s
|
|
63
|
+
return nil if wildcard.empty? || wildcard.start_with?('/')
|
|
64
|
+
return nil if UNSAFE_PATH_FRAGMENTS.any? { |fragment| wildcard.include?(fragment) }
|
|
65
|
+
|
|
66
|
+
"/#{wildcard}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Authoritative SSRF check: forwarding must never leave the executor origin.
|
|
70
|
+
def same_origin?(base, path)
|
|
71
|
+
base_uri = URI.parse(base)
|
|
72
|
+
target = URI.parse("#{base}#{path}")
|
|
73
|
+
target.scheme == base_uri.scheme && target.host == base_uri.host && target.port == base_uri.port
|
|
74
|
+
rescue URI::InvalidURIError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def forwarded_request_headers
|
|
79
|
+
request.headers.env.each_with_object({}) do |(key, value), acc|
|
|
80
|
+
name = http_header_name(key.to_s)
|
|
81
|
+
next unless name
|
|
82
|
+
next if SKIPPED_HEADERS.include?(name.downcase)
|
|
83
|
+
next if value.nil? || value.to_s.empty?
|
|
84
|
+
|
|
85
|
+
acc[name] = value.to_s
|
|
55
86
|
end
|
|
56
87
|
end
|
|
57
88
|
|
|
89
|
+
def http_header_name(env_key)
|
|
90
|
+
if env_key.start_with?('HTTP_')
|
|
91
|
+
titleize_header(env_key.delete_prefix('HTTP_'))
|
|
92
|
+
elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(env_key)
|
|
93
|
+
titleize_header(env_key)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def titleize_header(rack_name)
|
|
98
|
+
rack_name.split('_').map(&:capitalize).join('-')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Strip the routing key (the glob) and Rails internals so only real query params reach the executor.
|
|
58
102
|
def forwarded_query
|
|
59
103
|
params
|
|
60
|
-
.except(:
|
|
104
|
+
.except(:path, :controller, :action, :format)
|
|
61
105
|
.to_unsafe_h
|
|
62
106
|
end
|
|
63
107
|
|
|
64
|
-
def forwarded_body
|
|
65
|
-
return nil if
|
|
108
|
+
def forwarded_body
|
|
109
|
+
return nil if request.get? || request.head?
|
|
110
|
+
|
|
111
|
+
request.raw_post.presence
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Forward executor response headers (minus hop-by-hop) so executor-set headers survive the proxy.
|
|
115
|
+
def forward_response_headers(upstream_response)
|
|
116
|
+
upstream_response.headers.each do |name, value|
|
|
117
|
+
next if name.nil? || SKIPPED_HEADERS.include?(name.to_s.downcase)
|
|
118
|
+
|
|
119
|
+
forwarded = value.is_a?(Array) ? value.join(', ') : value.to_s
|
|
120
|
+
next if forwarded.empty?
|
|
66
121
|
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
response.headers[name.to_s] = forwarded
|
|
123
|
+
end
|
|
69
124
|
end
|
|
70
125
|
end
|
|
71
126
|
end
|
data/config/routes.rb
CHANGED
|
@@ -23,10 +23,10 @@ ForestLiana::Engine.routes.draw do
|
|
|
23
23
|
# Scopes
|
|
24
24
|
post '/scope-cache-invalidation' => 'scopes#invalidate_scope_cache'
|
|
25
25
|
|
|
26
|
-
# Workflow executor proxy
|
|
26
|
+
# Workflow executor proxy: single catch-all forwarding every verb/sub-path verbatim.
|
|
27
|
+
# Mounted only when ForestLiana.workflow_executor_url is set.
|
|
27
28
|
if ForestLiana.workflow_executor_url.present?
|
|
28
|
-
|
|
29
|
-
post '_internal/workflow-executions/:run_id/trigger' => 'workflow_executions#trigger'
|
|
29
|
+
match '_internal/executor/*path' => 'workflow_executions#proxy', via: :all
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Stripe Integration
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -31,7 +31,16 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
31
31
|
instance_double(
|
|
32
32
|
HTTParty::Response,
|
|
33
33
|
parsed_response: { 'id' => run_id, 'state' => 'pending' },
|
|
34
|
-
code: 200
|
|
34
|
+
code: 200,
|
|
35
|
+
headers: {
|
|
36
|
+
'content-type' => 'application/json',
|
|
37
|
+
# arbitrary executor response header — must be forwarded untouched.
|
|
38
|
+
'x-executor-custom' => 'passthrough-value',
|
|
39
|
+
# hop-by-hop response header — must be dropped, not forwarded.
|
|
40
|
+
'transfer-encoding' => 'chunked',
|
|
41
|
+
# body-framing header — meaningless once the body is re-serialized; must be dropped.
|
|
42
|
+
'content-encoding' => 'gzip'
|
|
43
|
+
}
|
|
35
44
|
)
|
|
36
45
|
end
|
|
37
46
|
|
|
@@ -42,77 +51,130 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
42
51
|
|
|
43
52
|
allow(HTTParty).to receive(:get).and_return(executor_response)
|
|
44
53
|
allow(HTTParty).to receive(:post).and_return(executor_response)
|
|
54
|
+
allow(HTTParty).to receive(:delete).and_return(executor_response)
|
|
45
55
|
end
|
|
46
56
|
|
|
47
|
-
describe '
|
|
48
|
-
it 'forwards GET
|
|
49
|
-
get "/forest/_internal/
|
|
57
|
+
describe 'generic forwarding' do
|
|
58
|
+
it 'forwards a run GET (caller includes runs/), preserving the sub-path and query verbatim' do
|
|
59
|
+
get "/forest/_internal/executor/runs/#{run_id}", params: { foo: 'bar' }, headers: auth_headers
|
|
50
60
|
|
|
51
61
|
expect(HTTParty).to have_received(:get).with(
|
|
52
62
|
"#{executor_url}/runs/#{run_id}",
|
|
63
|
+
hash_including(query: hash_including('foo' => 'bar'))
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'forwards a POST trigger with the raw body untouched (no reshaping)' do
|
|
68
|
+
raw = { step: 'approve', value: 42 }.to_json
|
|
69
|
+
|
|
70
|
+
post(
|
|
71
|
+
"/forest/_internal/executor/runs/#{run_id}/trigger",
|
|
72
|
+
params: raw,
|
|
73
|
+
headers: auth_headers
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(HTTParty).to have_received(:post).with(
|
|
77
|
+
"#{executor_url}/runs/#{run_id}/trigger",
|
|
78
|
+
hash_including(body: raw)
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'forwards a non-runs route verbatim (no /runs prefix injected)' do
|
|
83
|
+
delete '/forest/_internal/executor/mcp-oauth-credentials', headers: auth_headers
|
|
84
|
+
|
|
85
|
+
expect(HTTParty).to have_received(:delete).with(
|
|
86
|
+
"#{executor_url}/mcp-oauth-credentials",
|
|
87
|
+
anything
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'forwards client headers (e.g. Authorization / Cookie) to the executor' do
|
|
92
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
93
|
+
|
|
94
|
+
expect(HTTParty).to have_received(:get).with(
|
|
95
|
+
anything,
|
|
53
96
|
hash_including(
|
|
54
97
|
headers: hash_including(
|
|
55
98
|
'Authorization' => "Bearer #{bearer_token}",
|
|
56
99
|
'Cookie' => 'forest_session_token=session-xyz'
|
|
57
|
-
)
|
|
58
|
-
query: hash_including('foo' => 'bar')
|
|
100
|
+
)
|
|
59
101
|
)
|
|
60
102
|
)
|
|
61
103
|
end
|
|
62
104
|
|
|
105
|
+
it 'does not follow redirects (a 3xx Location must not escape the executor origin)' do
|
|
106
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
107
|
+
|
|
108
|
+
expect(HTTParty).to have_received(:get).with(
|
|
109
|
+
anything,
|
|
110
|
+
hash_including(follow_redirects: false)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
63
114
|
it 'returns the executor status and body verbatim' do
|
|
64
|
-
get "/forest/_internal/
|
|
115
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
65
116
|
|
|
66
117
|
expect(response.status).to eq(200)
|
|
67
118
|
expect(JSON.parse(response.body)).to eq('id' => run_id, 'state' => 'pending')
|
|
68
119
|
end
|
|
69
120
|
|
|
70
|
-
it '
|
|
71
|
-
get "/forest/_internal/
|
|
121
|
+
it 'forwards executor response headers except hop-by-hop / encoding ones' do
|
|
122
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
72
123
|
|
|
73
|
-
expect(response.
|
|
74
|
-
expect(
|
|
124
|
+
expect(response.headers['x-executor-custom']).to eq('passthrough-value')
|
|
125
|
+
expect(response.headers['transfer-encoding']).to be_nil
|
|
126
|
+
# content-encoding would tell the client the re-serialized JSON is gzipped → corruption.
|
|
127
|
+
expect(response.headers['content-encoding']).to be_nil
|
|
75
128
|
end
|
|
76
|
-
end
|
|
77
129
|
|
|
78
|
-
|
|
79
|
-
|
|
130
|
+
it 'does not forward accept-encoding (would defeat transparent gzip decompression)' do
|
|
131
|
+
get "/forest/_internal/executor/runs/#{run_id}",
|
|
132
|
+
headers: auth_headers.merge('Accept-Encoding' => 'gzip, deflate')
|
|
80
133
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
params: trigger_body.to_json,
|
|
85
|
-
headers: auth_headers
|
|
134
|
+
expect(HTTParty).to have_received(:get).with(
|
|
135
|
+
anything,
|
|
136
|
+
hash_including(headers: hash_excluding('Accept-Encoding'))
|
|
86
137
|
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
87
140
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
141
|
+
describe 'SSRF guard (cannot escape the executor origin)' do
|
|
142
|
+
[
|
|
143
|
+
'..',
|
|
144
|
+
'../mcp-oauth-credentials',
|
|
145
|
+
"#{'run_abc123'}/../../mcp-oauth-credentials",
|
|
146
|
+
'%2e%2e/mcp-oauth-credentials'
|
|
147
|
+
].each do |evil_path|
|
|
148
|
+
it "rejects #{evil_path.inspect} with 404 and never forwards" do
|
|
149
|
+
get "/forest/_internal/executor/#{evil_path}", headers: auth_headers
|
|
150
|
+
|
|
151
|
+
expect(response.status).to eq(404)
|
|
152
|
+
expect(HTTParty).not_to have_received(:get)
|
|
153
|
+
end
|
|
95
154
|
end
|
|
155
|
+
end
|
|
96
156
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
params: trigger_body.to_json,
|
|
101
|
-
headers: auth_headers
|
|
102
|
-
)
|
|
157
|
+
describe 'authentication' do
|
|
158
|
+
it 'rejects unauthenticated requests with 401' do
|
|
159
|
+
get "/forest/_internal/executor/runs/#{run_id}"
|
|
103
160
|
|
|
104
|
-
expect(response.status).to eq(
|
|
105
|
-
expect(
|
|
161
|
+
expect(response.status).to eq(401)
|
|
162
|
+
expect(HTTParty).not_to have_received(:get)
|
|
106
163
|
end
|
|
107
164
|
end
|
|
108
165
|
|
|
109
166
|
describe 'when the executor returns an error status' do
|
|
110
167
|
let(:executor_response) do
|
|
111
|
-
instance_double(
|
|
168
|
+
instance_double(
|
|
169
|
+
HTTParty::Response,
|
|
170
|
+
parsed_response: { 'error' => 'invalid_step' },
|
|
171
|
+
code: 422,
|
|
172
|
+
headers: { 'content-type' => 'application/json' }
|
|
173
|
+
)
|
|
112
174
|
end
|
|
113
175
|
|
|
114
176
|
it 'forwards the executor status and body to the client' do
|
|
115
|
-
get "/forest/_internal/
|
|
177
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
116
178
|
|
|
117
179
|
expect(response.status).to eq(422)
|
|
118
180
|
expect(JSON.parse(response.body)).to eq('error' => 'invalid_step')
|
|
@@ -125,7 +187,20 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
125
187
|
end
|
|
126
188
|
|
|
127
189
|
it 'returns 503 service_unavailable' do
|
|
128
|
-
get "/forest/_internal/
|
|
190
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
191
|
+
|
|
192
|
+
expect(response.status).to eq(503)
|
|
193
|
+
expect(JSON.parse(response.body)).to eq('error' => 'workflow_executor_unreachable')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe 'when the executor connection fails at the transport level (e.g. SSL)' do
|
|
198
|
+
before do
|
|
199
|
+
allow(HTTParty).to receive(:get).and_raise(OpenSSL::SSL::SSLError.new('cert verify failed'))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'returns 503 rather than a generic 500' do
|
|
203
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
129
204
|
|
|
130
205
|
expect(response.status).to eq(503)
|
|
131
206
|
expect(JSON.parse(response.body)).to eq('error' => 'workflow_executor_unreachable')
|
|
@@ -145,7 +220,7 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
145
220
|
# Note: routes are mounted at boot based on workflow_executor_url being
|
|
146
221
|
# present. This test exercises the runtime guard inside the controller
|
|
147
222
|
# for scenarios where config is mutated after boot (e.g. tests).
|
|
148
|
-
get "/forest/_internal/
|
|
223
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
149
224
|
|
|
150
225
|
expect(response.status).to eq(404)
|
|
151
226
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_liana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 9.
|
|
4
|
+
version: 9.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sandro Munda
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|