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: 92281c1baf13dfbbdbe418f2a11833ad15a665c1109f1e4ad0a5f395e32ab090
4
- data.tar.gz: 71946c62175f706e11528dbac2a3e7600f780a7847596be724c74675ef8299a0
3
+ metadata.gz: c37d0a61be7a37b80bab933cf4892cef1aceb25a5f8351d56c66dbd4d07e6c4d
4
+ data.tar.gz: d857a4ebb09833f6c99afb44d4daf2e06b396689331c6e28359fe78117e78727
5
5
  SHA512:
6
- metadata.gz: f6eced39681d8c424791ed625807cb3ce9e8e741a9ed7f1ca711f225219219d2415cafa222b8df4eaa99185f57c98ca2f8766df724719d36e791af695ad1f2ee
7
- data.tar.gz: 4df825ec3296694e8fcf7cdc0d7eb86a481667af477ce9b7e500ba69afdc1bb86d343049f2493d896c9a70dc19100f984dcd301e5da80703520cf17ae0121dcc
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
- FORWARDED_HEADERS = %w[Authorization Cookie].freeze
6
- UPSTREAM_TIMEOUT_IN_SECONDS = 10
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
- def show
17
- forward_to_executor(method: :get, suffix: '')
18
- end
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
- head :not_found
30
- return
31
- end
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
- method,
36
- url,
37
- headers: forwarded_headers,
39
+ request.request_method_symbol,
40
+ "#{normalized_base}#{path}",
41
+ headers: forwarded_request_headers,
38
42
  query: forwarded_query,
39
- body: forwarded_body(method),
43
+ body: forwarded_body,
40
44
  verify: Rails.env.production?,
41
- timeout: UPSTREAM_TIMEOUT_IN_SECONDS
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
- def forwarded_headers
51
- base = { 'Content-Type' => 'application/json' }
52
- FORWARDED_HEADERS.each_with_object(base) do |name, acc|
53
- value = request.headers[name]
54
- acc[name] = value if value.present?
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(:run_id, :controller, :action, :format)
104
+ .except(:path, :controller, :action, :format)
61
105
  .to_unsafe_h
62
106
  end
63
107
 
64
- def forwarded_body(method)
65
- return nil if method == :get
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
- raw = request.raw_post
68
- raw.presence
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 (mounted only when ForestLiana.workflow_executor_url is set)
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
- get '_internal/workflow-executions/:run_id' => 'workflow_executions#show'
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.19.0"
2
+ VERSION = "9.20.0"
3
3
  end
@@ -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 'GET /forest/_internal/workflow-executions/:run_id' do
48
- it 'forwards GET to the executor /runs/:run_id endpoint' do
49
- get "/forest/_internal/workflow-executions/#{run_id}", params: { foo: 'bar' }, headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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 'rejects unauthenticated requests with 401' do
71
- get "/forest/_internal/workflow-executions/#{run_id}"
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.status).to eq(401)
74
- expect(HTTParty).not_to have_received(:get)
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
- describe 'POST /forest/_internal/workflow-executions/:run_id/trigger' do
79
- let(:trigger_body) { { step: 'approve', value: 42 } }
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
- it 'forwards POST to the executor /runs/:run_id/trigger endpoint with the body' do
82
- post(
83
- "/forest/_internal/workflow-executions/#{run_id}/trigger",
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
- expect(HTTParty).to have_received(:post).with(
89
- "#{executor_url}/runs/#{run_id}/trigger",
90
- hash_including(
91
- headers: hash_including('Authorization' => "Bearer #{bearer_token}"),
92
- body: trigger_body.to_json
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
- it 'returns the executor response' do
98
- post(
99
- "/forest/_internal/workflow-executions/#{run_id}/trigger",
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(200)
105
- expect(JSON.parse(response.body)).to eq('id' => run_id, 'state' => 'pending')
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(HTTParty::Response, parsed_response: { 'error' => 'invalid_step' }, code: 422)
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/workflow-executions/#{run_id}", headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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.19.0
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-10 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails