Skip to content

Commit 16466bd

Browse files
authored
Merge pull request #68 from aws/ilbe/proxy-bypass
fix: bypass proxy for Runtime API calls
2 parents 80e7dfc + c7efdc5 commit 16466bd

8 files changed

Lines changed: 195 additions & 13 deletions

File tree

‎.github/workflows/dockerized-test.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,13 @@ jobs:
3939
suiteFileArray: '["./test/dockerized/suites/*.json"]'
4040
dockerImageName: 'local/test'
4141
taskFolder: './test/dockerized/tasks'
42+
43+
- name: Build the proxy image
44+
run: docker build . -t local/test-proxy -f Dockerfile.test.proxy
45+
46+
- name: Run proxy tests
47+
uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main
48+
with:
49+
suiteFileArray: '["./test/dockerized/suites/proxy/*.json"]'
50+
dockerImageName: 'local/test-proxy'
51+
taskFolder: './test/dockerized/tasks'

‎Dockerfile.test‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ ADD test/dockerized/tasks /var/task
55
RUN gem uninstall aws_lambda_ric --executables
66
ADD pkg /tmp/pkg
77
RUN gem install /tmp/pkg/aws_lambda_ric-*.gem
8-
RUN rm -rf /tmp/pkg
8+
RUN rm -rf /tmp/pkg

‎Dockerfile.test.proxy‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Variant of Dockerfile.test used only for the proxy-regression suite.
2+
# Extends the standard test image with an unreachable HTTP_PROXY and an
3+
# entrypoint that binds RIE's Runtime API to the container's own
4+
# non-loopback hostname. Kept separate from Dockerfile.test so the other
5+
# suites keep running against a plain RIE-on-loopback setup.
6+
#
7+
# Requires local/test to be built first (see .github/workflows/dockerized-test.yml).
8+
FROM local/test
9+
10+
ENV HTTP_PROXY=http://127.0.0.1:1
11+
ENV http_proxy=http://127.0.0.1:1
12+
13+
COPY test/dockerized/entrypoint.sh /entrypoint.sh
14+
RUN chmod +x /entrypoint.sh
15+
ENTRYPOINT ["/entrypoint.sh"]

‎lib/aws_lambda_ric/lambda_server.rb‎

Lines changed: 19 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,19 @@ 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 endpoint must never be proxied. The nil proxy argument
93+
# disables Net::HTTP's default :ENV proxy resolution, which would otherwise
94+
# route calls through a customer-configured PROXY.
95+
def build_client(uri)
96+
Net::HTTP.new(uri.host, uri.port, nil)
97+
end
98+
99+
def post(uri, body, headers)
100+
build_client(uri).start do |connection|
101+
connection.post(uri.path, body, headers)
102+
end
103+
end
89104
end

‎test/dockerized/entrypoint.sh‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/sh
2+
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
#
4+
5+
set -eu
6+
7+
if [ "$#" -ne 1 ]; then
8+
echo "entrypoint requires the handler name as first argument" 1>&2
9+
exit 142
10+
fi
11+
export _HANDLER="$1"
12+
13+
# Resolve the container's own hostname to its non-loopback IPv4 (docker
14+
# writes this to /etc/hosts for us on eth0).
15+
RIC_HOST="$(getent hosts "$HOSTNAME" | awk '{print $1; exit}')"
16+
if [ -z "$RIC_HOST" ]; then
17+
echo "entrypoint could not resolve \$HOSTNAME ($HOSTNAME)" 1>&2
18+
exit 143
19+
fi
20+
21+
exec /usr/local/bin/aws-lambda-rie \
22+
--runtime-api-address "$RIC_HOST:9001" \
23+
/var/runtime/bootstrap
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"tests": [
3+
{
4+
"name": "test_ric_bypasses_http_proxy",
5+
"handler": "proxy.check_proxy_bypass",
6+
"request": {},
7+
"assertions": [
8+
{
9+
"response": "success"
10+
}
11+
]
12+
}
13+
]
14+
}

‎test/dockerized/tasks/proxy.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# The image (see Dockerfile.test) runs with HTTP_PROXY pointed at an
4+
# unreachable address and RIE's Runtime API bound to a non-loopback
5+
# hostname. If the proxy-bypass fix (PR #68) is in place the RIC bypasses
6+
# HTTP_PROXY for Runtime API calls and this handler runs to completion,
7+
# returning "success". Without the fix the RIC would try to reach the
8+
# unreachable proxy for next_invocation and this handler would never run.
9+
10+
def check_proxy_bypass(event:, context:)
11+
'success'
12+
end

‎test/unit/lambda_server_test.rb‎

Lines changed: 101 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,47 @@ 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, true) do |&block|
98+
block.call(conn_mock)
99+
true
100+
end
101+
conn_mock.expect(:post, nil, [path, body, headers])
102+
conn_mock
103+
end
104+
105+
def env_stub(name, value)
106+
previous = ENV[name]
107+
ENV[name] = value
108+
yield
109+
ensure
110+
ENV[name] = previous
72111
end
73112

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

0 commit comments

Comments
 (0)