forest_admin_agent 1.33.1 → 1.34.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: 9d606dd925365a5389470b0e1a2c0b42d73efd17d12ff543371ae6a5ee92e721
4
- data.tar.gz: 5c9cd14e9935df983cbd49dc12be9601982e7003517a4266fe9a7195e9b385c4
3
+ metadata.gz: 5eac03c350cacd4d12aa3408eb1bc3d0546f7d5999974335b870343609ac3375
4
+ data.tar.gz: 6d21b84fda83ad2f90bf157c8fc8a0b7f65710d1159cf771645a11f0349c8037
5
5
  SHA512:
6
- metadata.gz: fcea300520ed0e66411bb1c1a7187461768a628364e2e2213da2a3dfdfaa899d7f312f0ae0a03353c1bc11b357b97590dd138b6fb6118d2e1284dbd2ca2cb106
7
- data.tar.gz: 5002a6e10f7d98a09986dd392e88aa3c6bcd296fc87ad3b8ce8ea1b4c76866039b23c887ed23e974fc54535ae9d7725593f52ee83bdad004c5539e911b37f23b
6
+ metadata.gz: 3c5b3a78c4ac0fa919dfdbddf7518eac9520b36e47a5c258c3f97651fd12989f2fad2ef65a669c4ee6bf3862921ba91b505eb6c4d70a615001e32da927b7c7e1
7
+ data.tar.gz: 424a07e610f49273e9d12751369ade15ead966d6876f0bf70af4acb5b68444ab508f2b51ef31e3522c2c3b51cfb6fb7e3649d2993c18b194212ef270dfe8669e
@@ -1,45 +1,45 @@
1
1
  require 'faraday'
2
+ require 'uri'
2
3
 
3
4
  module ForestAdminAgent
4
5
  module Routes
5
6
  module Workflow
6
- # Forwards workflow-execution traffic from the agent to the workflow executor.
7
- # Mounted only when the integrator sets `workflow_executor_url`
7
+ # Generic proxy: forwards any sub-path/verb under AGENT_PREFIX to the executor verbatim, so a
8
+ # new executor route needs no change here. Mounted only when `workflow_executor_url` is set.
8
9
  class WorkflowExecutorProxy < AbstractAuthenticatedRoute
9
- AGENT_PREFIX = '/_internal/workflow-executions'.freeze
10
- EXECUTOR_PREFIX = '/runs'.freeze
11
- FORWARDED_HEADERS = %w[Authorization Cookie].freeze
12
- ROUTING_KEYS = %w[run_id route_alias controller action format].freeze
10
+ AGENT_PREFIX = '/_internal/executor'.freeze
11
+ # Never forwarded (request or response): hop-by-hop, Host, and body-framing headers.
12
+ # Faraday and `render json:` set their own length and de/recompress the body, so relaying
13
+ # the upstream length/encoding would mismatch the bytes we actually send — and forwarding
14
+ # accept-encoding would disable Faraday's transparent gzip decompression.
15
+ SKIPPED_HEADERS = %w[
16
+ connection keep-alive transfer-encoding upgrade te trailer
17
+ proxy-authenticate proxy-authorization host
18
+ content-length content-encoding accept-encoding
19
+ ].freeze
20
+ # Fragments that could let the wildcard leave the executor origin once decoded.
21
+ UNSAFE_PATH_FRAGMENTS = ['..', '%2e', '%2E', '\\', "\0"].freeze
13
22
  OPEN_TIMEOUT = 2
14
- GET_TIMEOUT = 10
15
- TRIGGER_TIMEOUT = 120
23
+ REQUEST_TIMEOUT = 120
16
24
 
17
25
  def setup_routes
18
26
  return self unless executor_configured?
19
27
 
20
28
  add_route(
21
- 'forest_workflow_run_show',
22
- 'get',
23
- "#{AGENT_PREFIX}/:run_id",
24
- ->(args) { handle_request(:get, args) }
25
- )
26
- add_route(
27
- 'forest_workflow_run_trigger',
28
- 'post',
29
- "#{AGENT_PREFIX}/:run_id/trigger",
30
- ->(args) { handle_request(:post, args) }
29
+ 'forest_workflow_executor_proxy',
30
+ :all,
31
+ "#{AGENT_PREFIX}/*path",
32
+ ->(args) { handle_request(args) }
31
33
  )
32
34
 
33
35
  self
34
36
  end
35
37
 
36
- def handle_request(method, args = {})
38
+ def handle_request(args = {})
37
39
  build(args)
38
40
 
39
- base_url = configured_executor_url
40
- run_id = args.dig(:params, 'run_id') || args.dig(:params, :run_id)
41
- path = build_path(run_id, method)
42
- response = forward(method, base_url, path, args)
41
+ method = (args[:method] || 'get').to_s.downcase.to_sym
42
+ response = forward(method, build_target_url(args), args)
43
43
 
44
44
  {
45
45
  content: response.body,
@@ -67,76 +67,98 @@ module ForestAdminAgent
67
67
  url.to_s.sub(%r{/+\z}, '')
68
68
  end
69
69
 
70
- def build_path(run_id, method)
71
- suffix = method == :post ? '/trigger' : ''
72
- "#{EXECUTOR_PREFIX}/#{run_id}#{suffix}"
70
+ def build_target_url(args)
71
+ base = configured_executor_url
72
+ path = build_executor_path(args.dig(:params, 'path') || args.dig(:params, :path))
73
+ reject_off_origin!(base, path)
74
+ query = args[:query_string].to_s
75
+ url = "#{base}#{path}"
76
+
77
+ query.empty? ? url : "#{url}?#{query}"
78
+ end
79
+
80
+ # First-pass rejection of escape attempts; the authoritative origin check is reject_off_origin!.
81
+ def build_executor_path(raw_path)
82
+ path = raw_path.to_s
83
+ raise Http::Exceptions::NotFoundError, 'Invalid workflow executor path' if unsafe_path?(path)
84
+
85
+ "/#{path}"
86
+ end
87
+
88
+ # Authoritative SSRF check: forwarding must never leave the executor origin.
89
+ def reject_off_origin!(base, path)
90
+ base_uri = URI.parse(base)
91
+ target = URI.parse("#{base}#{path}")
92
+ same = target.scheme == base_uri.scheme && target.host == base_uri.host &&
93
+ target.port == base_uri.port
94
+ raise Http::Exceptions::NotFoundError, 'Invalid workflow executor path' unless same
95
+ rescue URI::InvalidURIError
96
+ raise Http::Exceptions::NotFoundError, 'Invalid workflow executor path'
73
97
  end
74
98
 
75
- def forward(method, base_url, path, args)
76
- query = forwarded_query_params(args[:params])
99
+ def unsafe_path?(path)
100
+ return true if path.empty? || path.start_with?('/')
101
+
102
+ UNSAFE_PATH_FRAGMENTS.any? { |fragment| path.include?(fragment) }
103
+ end
104
+
105
+ def forward(method, target_url, args)
77
106
  headers = forwarded_request_headers(args[:headers])
78
- body = forwarded_body(method, args[:params])
79
- target_url = "#{base_url}#{path}"
107
+ body = method == :get ? nil : args[:body]
80
108
 
81
- client = build_client(timeout_for(method))
82
- client.run_request(method, target_url, body, headers) do |req|
83
- req.params.update(query) unless query.empty?
84
- end
109
+ build_client.run_request(method, target_url, body, headers)
85
110
  rescue Faraday::TimeoutError => e
86
111
  raise Http::Exceptions::ServiceUnavailableError.new('Workflow executor timed out', cause: e)
87
112
  rescue Faraday::ConnectionFailed => e
88
113
  raise Http::Exceptions::ServiceUnavailableError.new('Workflow executor unreachable', cause: e)
114
+ # Any other transport-level Faraday failure (SSL, etc.) is an executor-reachability problem,
115
+ # not a 500 — Faraday never raises on executor 4xx/5xx (no raise_error middleware).
116
+ rescue Faraday::Error => e
117
+ raise Http::Exceptions::ServiceUnavailableError.new('Workflow executor request failed', cause: e)
89
118
  end
90
119
 
91
- def timeout_for(method)
92
- method == :get ? GET_TIMEOUT : TRIGGER_TIMEOUT
93
- end
94
-
95
- def build_client(request_timeout)
96
- Faraday.new(request: { open_timeout: OPEN_TIMEOUT, timeout: request_timeout }) do |f|
97
- f.request :json
120
+ def build_client
121
+ Faraday.new(request: { open_timeout: OPEN_TIMEOUT, timeout: REQUEST_TIMEOUT }) do |f|
122
+ # No request :json middleware: body is forwarded raw (not reshaped).
98
123
  f.response :json, content_type: /\bjson$/
99
124
  f.adapter Faraday.default_adapter
100
125
  end
101
126
  end
102
127
 
103
- # Strip Rails-injected routing keys; keep only true client query params.
104
- def forwarded_query_params(params)
105
- return {} unless params.is_a?(Hash)
128
+ # `env` is the Rack env: real HTTP headers are the HTTP_* keys (+ CONTENT_TYPE/CONTENT_LENGTH);
129
+ # rack.*/action_dispatch.*/server vars are not headers and are dropped.
130
+ def forwarded_request_headers(env)
131
+ return {} unless env.is_a?(Hash)
106
132
 
107
- params.each_with_object({}) do |(key, value), acc|
108
- next if ROUTING_KEYS.include?(key.to_s)
109
- next if value.is_a?(Hash) || value.is_a?(Array) # 'data' body, etc.
133
+ env.each_with_object({}) do |(key, value), acc|
134
+ name = http_header_name(key.to_s)
135
+ next unless name
136
+ next if SKIPPED_HEADERS.include?(name.downcase)
137
+ next if value.nil? || value.to_s.empty?
110
138
 
111
- acc[key.to_s] = value
139
+ acc[name] = value.to_s
112
140
  end
113
141
  end
114
142
 
115
- def forwarded_request_headers(headers)
116
- return {} unless headers.is_a?(Hash)
117
-
118
- FORWARDED_HEADERS.each_with_object({}) do |name, acc|
119
- value = headers[name] || headers[name.downcase] || headers["HTTP_#{name.upcase}"]
120
- acc[name] = value if value && !value.to_s.empty?
143
+ def http_header_name(env_key)
144
+ if env_key.start_with?('HTTP_')
145
+ titleize_header(env_key.delete_prefix('HTTP_'))
146
+ elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(env_key)
147
+ titleize_header(env_key)
121
148
  end
122
149
  end
123
150
 
124
- def forwarded_body(method, params)
125
- return nil if method == :get
126
- return nil unless params.is_a?(Hash)
127
-
128
- # JSON request bodies arrive parsed under :data when sent as JSON:API,
129
- # or as the raw top-level params hash otherwise. Prefer :data when
130
- # present; fall back to a sanitized copy of params.
131
- body = params['data'] || params[:data]
132
- return body if body
133
-
134
- params.reject { |key, _| ROUTING_KEYS.include?(key.to_s) }
151
+ def titleize_header(rack_name)
152
+ rack_name.split('_').map(&:capitalize).join('-')
135
153
  end
136
154
 
137
155
  def forwarded_response_headers(response)
138
- content_type = response.headers['content-type'] || response.headers['Content-Type']
139
- content_type ? { 'Content-Type' => content_type } : {}
156
+ response.headers.each_with_object({}) do |(name, value), acc|
157
+ next if name.nil? || SKIPPED_HEADERS.include?(name.to_s.downcase)
158
+ next if value.nil? || value.to_s.empty?
159
+
160
+ acc[name.to_s] = value.to_s
161
+ end
140
162
  end
141
163
  end
142
164
  end
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.33.1"
9
+ LIANA_VERSION = "1.34.0"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.33.1"
2
+ VERSION = "1.34.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.33.1
4
+ version: 1.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-06-16 00:00:00.000000000 Z
12
+ date: 2026-06-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport