Skip to content

Commit dce7e8d

Browse files
committed
fix: bypass proxy for Runtime API calls
The Runtime API client used Net::HTTP.post and Net::HTTP.new(host, port), both of which default to :ENV proxy resolution and honor HTTP(S)_PROXY. A customer-configured proxy then routed calls to the API endpoint through the proxy, so a proxy in the environment could break function init and result delivery even though it should never affect communication with the API. Route the invocation poll and the three write-backs through a single client built with a nil proxy argument, which disables Net::HTTP's default proxy resolution so the link-local API endpoint is never proxied.
1 parent 7ae6e6c commit dce7e8d

2 files changed

Lines changed: 108 additions & 12 deletions

File tree

‎lib/aws_lambda_ric/lambda_server.rb‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def initialize(server_address, user_agent)
2020
def next_invocation
2121
next_invocation_uri = URI(@server_address + '/runtime/invocation/next')
2222
begin
23-
http = Net::HTTP.new(next_invocation_uri.host, next_invocation_uri.port)
23+
http = build_client(next_invocation_uri)
2424
http.read_timeout = LONG_TIMEOUT_MS
2525
resp = http.start do |connection|
2626
connection.get(next_invocation_uri.path, { 'User-Agent' => @user_agent })
@@ -49,7 +49,7 @@ def send_response(request_id:, response_object:, content_type: 'application/json
4949
if content_type == 'application/unknown'
5050
response_object = response_object.read
5151
end
52-
Net::HTTP.post(
52+
post(
5353
response_uri,
5454
response_object,
5555
{ 'Content-Type' => content_type, 'User-Agent' => @user_agent }
@@ -64,7 +64,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:)
6464
begin
6565
headers = { 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent }
6666
headers['Lambda-Runtime-Function-XRay-Error-Cause'] = xray_cause if xray_cause.bytesize < MAX_HEADER_SIZE_BYTES
67-
Net::HTTP.post(
67+
post(
6868
response_uri,
6969
error_object.to_json,
7070
headers
@@ -77,7 +77,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:)
7777
def send_init_error(error_object:, error:)
7878
uri = URI("#{@server_address}/runtime/init/error")
7979
begin
80-
Net::HTTP.post(
80+
post(
8181
uri,
8282
error_object.to_json,
8383
{ 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent }
@@ -86,4 +86,18 @@ def send_init_error(error_object:, error:)
8686
raise LambdaErrors::LambdaRuntimeInitError.new(e)
8787
end
8888
end
89+
90+
private
91+
92+
# The Runtime API is can be a link-local endpoint that must never be proxied. The nil
93+
# proxy argument disables Net::HTTP's default :ENV proxy resolution.
94+
def build_client(uri)
95+
Net::HTTP.new(uri.host, uri.port, nil)
96+
end
97+
98+
def post(uri, body, headers)
99+
build_client(uri).start do |connection|
100+
connection.post(uri.path, body, headers)
101+
end
102+
end
89103
end

‎test/unit/lambda_server_test.rb‎

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative '../../lib/aws_lambda_ric/lambda_errors'
44
require_relative '../../lib/aws_lambda_ric/lambda_server'
55
require 'net/http'
6+
require 'socket'
67
require 'minitest/autorun'
78

89
class LambdaServerTest < Minitest::Test
@@ -37,10 +38,9 @@ def test_post_invocation_error_with_large_xray_cause
3738
headers = {'Lambda-Runtime-Function-Error-Type' => @error.runtime_error_type,
3839
'Lambda-Runtime-Function-XRay-Error-Cause' => large_xray_cause,
3940
'User-Agent' => @mock_user_agent}
40-
post_mock = Minitest::Mock.new
41-
post_mock.expect :call, nil, [@error_uri, @error.to_lambda_response.to_json, headers]
41+
conn_mock = mock_post_connection(@error_uri.path, @error.to_lambda_response.to_json, headers)
4242

43-
Net::HTTP.stub(:post, post_mock) do
43+
Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do
4444
@under_test.send_error_response(
4545
request_id: @request_id,
4646
error_object: @error.to_lambda_response,
@@ -49,17 +49,16 @@ def test_post_invocation_error_with_large_xray_cause
4949
)
5050
end
5151

52-
assert_mock post_mock
52+
assert_mock conn_mock
5353
end
5454

5555
def test_post_invocation_error_with_too_large_xray_cause
5656
too_large_xray_cause = 'a' * 1024 * 1024
5757
headers = {'Lambda-Runtime-Function-Error-Type' => @error.runtime_error_type,
5858
'User-Agent' => @mock_user_agent}
59-
post_mock = Minitest::Mock.new
60-
post_mock.expect :call, nil, [@error_uri, @error.to_lambda_response.to_json, headers]
59+
conn_mock = mock_post_connection(@error_uri.path, @error.to_lambda_response.to_json, headers)
6160

62-
Net::HTTP.stub(:post, post_mock) do
61+
Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do
6362
@under_test.send_error_response(
6463
request_id: @request_id,
6564
error_object: @error.to_lambda_response,
@@ -68,7 +67,44 @@ def test_post_invocation_error_with_too_large_xray_cause
6867
)
6968
end
7069

71-
assert_mock post_mock
70+
assert_mock conn_mock
71+
end
72+
73+
# Regression: with a proxy in the environment, the response must still reach
74+
# the Runtime API directly
75+
def test_send_response_reaches_api_and_not_proxy_when_proxy_is_set
76+
api = RecordingServer.new
77+
proxy = RecordingServer.new
78+
79+
['HTTP_PROXY', 'http_proxy'].each do |var|
80+
api.reset
81+
proxy.reset
82+
env_stub(var, "http://#{proxy.address}") do
83+
client = RapidClient.new(api.address, @mock_user_agent)
84+
client.send_response(request_id: @request_id, response_object: 'response')
85+
86+
assert_equal 1, api.hits, 'response should reach the Runtime API'
87+
assert_equal 0, proxy.hits, "response must not be routed through #{var}"
88+
end
89+
end
90+
ensure
91+
api&.close
92+
proxy&.close
93+
end
94+
95+
def mock_post_connection(path, body, headers)
96+
conn_mock = Minitest::Mock.new
97+
conn_mock.expect(:start, nil) { |&block| block.call(conn_mock) }
98+
conn_mock.expect(:post, nil, [path, body, headers])
99+
conn_mock
100+
end
101+
102+
def env_stub(name, value)
103+
previous = ENV[name]
104+
ENV[name] = value
105+
yield
106+
ensure
107+
ENV[name] = previous
72108
end
73109

74110
def mock_next_invocation_response()
@@ -129,3 +165,49 @@ def test_next_invocation_with_null_tenant_id_header
129165
assert_mock get_mock
130166
end
131167
end
168+
169+
# A minimal HTTP server that binds to a non-loopback address and counts the
170+
# requests it receives. Non-loopback matters: Net::HTTP never proxies loopback,
171+
# so a 127.0.0.1 target would bypass the proxy.
172+
class RecordingServer
173+
def initialize
174+
ip = Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? && !a.ipv4_multicast? }
175+
raise 'no non-loopback IPv4 interface available' unless ip
176+
177+
@server = TCPServer.new(ip.ip_address, 0)
178+
@hits = 0
179+
@lock = Mutex.new
180+
@thread = Thread.new { accept_loop }
181+
end
182+
183+
def address
184+
"#{@server.addr[3]}:#{@server.addr[1]}"
185+
end
186+
187+
def hits
188+
@lock.synchronize { @hits }
189+
end
190+
191+
def reset
192+
@lock.synchronize { @hits = 0 }
193+
end
194+
195+
def close
196+
@thread&.kill
197+
@server&.close
198+
end
199+
200+
private
201+
202+
def accept_loop
203+
loop do
204+
client = @server.accept
205+
@lock.synchronize { @hits += 1 }
206+
client.gets
207+
client.write("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n")
208+
client.close
209+
end
210+
rescue IOError, Errno::EBADF
211+
# server closed
212+
end
213+
end

0 commit comments

Comments
 (0)