Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/dockerized-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@ jobs:
suiteFileArray: '["./test/dockerized/suites/*.json"]'
dockerImageName: 'local/test'
taskFolder: './test/dockerized/tasks'

- name: Build the proxy image
run: docker build . -t local/test-proxy -f Dockerfile.test.proxy

- name: Run proxy tests
uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main
with:
suiteFileArray: '["./test/dockerized/suites/proxy/*.json"]'
dockerImageName: 'local/test-proxy'
taskFolder: './test/dockerized/tasks'
2 changes: 1 addition & 1 deletion Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ ADD test/dockerized/tasks /var/task
RUN gem uninstall aws_lambda_ric --executables
ADD pkg /tmp/pkg
RUN gem install /tmp/pkg/aws_lambda_ric-*.gem
RUN rm -rf /tmp/pkg
RUN rm -rf /tmp/pkg
15 changes: 15 additions & 0 deletions Dockerfile.test.proxy
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Variant of Dockerfile.test used only for the proxy-regression suite.
# Extends the standard test image with an unreachable HTTP_PROXY and an
# entrypoint that binds RIE's Runtime API to the container's own
# non-loopback hostname. Kept separate from Dockerfile.test so the other
# suites keep running against a plain RIE-on-loopback setup.
#
# Requires local/test to be built first (see .github/workflows/dockerized-test.yml).
FROM local/test

ENV HTTP_PROXY=http://127.0.0.1:1
ENV http_proxy=http://127.0.0.1:1

COPY test/dockerized/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
23 changes: 19 additions & 4 deletions lib/aws_lambda_ric/lambda_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def initialize(server_address, user_agent)
def next_invocation
next_invocation_uri = URI(@server_address + '/runtime/invocation/next')
begin
http = Net::HTTP.new(next_invocation_uri.host, next_invocation_uri.port)
http = build_client(next_invocation_uri)
http.read_timeout = LONG_TIMEOUT_MS
resp = http.start do |connection|
connection.get(next_invocation_uri.path, { 'User-Agent' => @user_agent })
Expand Down Expand Up @@ -49,7 +49,7 @@ def send_response(request_id:, response_object:, content_type: 'application/json
if content_type == 'application/unknown'
response_object = response_object.read
end
Net::HTTP.post(
post(
response_uri,
response_object,
{ 'Content-Type' => content_type, 'User-Agent' => @user_agent }
Expand All @@ -64,7 +64,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:)
begin
headers = { 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent }
headers['Lambda-Runtime-Function-XRay-Error-Cause'] = xray_cause if xray_cause.bytesize < MAX_HEADER_SIZE_BYTES
Net::HTTP.post(
post(
response_uri,
error_object.to_json,
headers
Expand All @@ -77,7 +77,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:)
def send_init_error(error_object:, error:)
uri = URI("#{@server_address}/runtime/init/error")
begin
Net::HTTP.post(
post(
uri,
error_object.to_json,
{ 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent }
Expand All @@ -86,4 +86,19 @@ def send_init_error(error_object:, error:)
raise LambdaErrors::LambdaRuntimeInitError.new(e)
end
end

private

# The Runtime API endpoint must never be proxied. The nil proxy argument
# disables Net::HTTP's default :ENV proxy resolution, which would otherwise
# route calls through a customer-configured PROXY.
def build_client(uri)
Net::HTTP.new(uri.host, uri.port, nil)
end

def post(uri, body, headers)
build_client(uri).start do |connection|
connection.post(uri.path, body, headers)
end
end
end
23 changes: 23 additions & 0 deletions test/dockerized/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#

set -eu

if [ "$#" -ne 1 ]; then
echo "entrypoint requires the handler name as first argument" 1>&2
exit 142
fi
export _HANDLER="$1"

# Resolve the container's own hostname to its non-loopback IPv4 (docker
# writes this to /etc/hosts for us on eth0).
RIC_HOST="$(getent hosts "$HOSTNAME" | awk '{print $1; exit}')"
if [ -z "$RIC_HOST" ]; then
echo "entrypoint could not resolve \$HOSTNAME ($HOSTNAME)" 1>&2
exit 143
fi

exec /usr/local/bin/aws-lambda-rie \
--runtime-api-address "$RIC_HOST:9001" \
/var/runtime/bootstrap
14 changes: 14 additions & 0 deletions test/dockerized/suites/proxy/proxy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"tests": [
{
"name": "test_ric_bypasses_http_proxy",
"handler": "proxy.check_proxy_bypass",
"request": {},
"assertions": [
{
"response": "success"
}
]
}
]
}
12 changes: 12 additions & 0 deletions test/dockerized/tasks/proxy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# The image (see Dockerfile.test) runs with HTTP_PROXY pointed at an
# unreachable address and RIE's Runtime API bound to a non-loopback
# hostname. If the proxy-bypass fix (PR #68) is in place the RIC bypasses
# HTTP_PROXY for Runtime API calls and this handler runs to completion,
# returning "success". Without the fix the RIC would try to reach the
# unreachable proxy for next_invocation and this handler would never run.

def check_proxy_bypass(event:, context:)
'success'
end
109 changes: 101 additions & 8 deletions test/unit/lambda_server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative '../../lib/aws_lambda_ric/lambda_errors'
require_relative '../../lib/aws_lambda_ric/lambda_server'
require 'net/http'
require 'socket'
require 'minitest/autorun'

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

Net::HTTP.stub(:post, post_mock) do
Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do
@under_test.send_error_response(
request_id: @request_id,
error_object: @error.to_lambda_response,
Expand All @@ -49,17 +49,16 @@ def test_post_invocation_error_with_large_xray_cause
)
end

assert_mock post_mock
assert_mock conn_mock
end

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

Net::HTTP.stub(:post, post_mock) do
Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do
@under_test.send_error_response(
request_id: @request_id,
error_object: @error.to_lambda_response,
Expand All @@ -68,7 +67,47 @@ def test_post_invocation_error_with_too_large_xray_cause
)
end

assert_mock post_mock
assert_mock conn_mock
end

# Regression: with a proxy in the environment, the response must still reach
# the Runtime API directly
def test_send_response_reaches_api_and_not_proxy_when_proxy_is_set
api = RecordingServer.new
proxy = RecordingServer.new

['HTTP_PROXY', 'http_proxy'].each do |var|
api.reset
proxy.reset
env_stub(var, "http://#{proxy.address}") do
client = RapidClient.new(api.address, @mock_user_agent)
client.send_response(request_id: @request_id, response_object: 'response')

assert_equal 1, api.hits, 'response should reach the Runtime API'
assert_equal 0, proxy.hits, "response must not be routed through #{var}"
end
end
ensure
api&.close
proxy&.close
end

def mock_post_connection(path, body, headers)
conn_mock = Minitest::Mock.new
conn_mock.expect(:start, true) do |&block|
block.call(conn_mock)
true
end
conn_mock.expect(:post, nil, [path, body, headers])
conn_mock
end

def env_stub(name, value)
previous = ENV[name]
ENV[name] = value
yield
ensure
ENV[name] = previous
end

def mock_next_invocation_response()
Expand Down Expand Up @@ -129,3 +168,57 @@ def test_next_invocation_with_null_tenant_id_header
assert_mock get_mock
end
end

# A minimal HTTP server that binds to a non-loopback address and counts the
# requests it receives. Non-loopback matters: Net::HTTP never proxies loopback,
# so a 127.0.0.1 target would bypass the proxy.
class RecordingServer
def initialize
ip = Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? && !a.ipv4_multicast? }
raise 'no non-loopback IPv4 interface available' unless ip

@server = TCPServer.new(ip.ip_address, 0)
@hits = 0
@lock = Mutex.new
@thread = Thread.new { accept_loop }
end

def address
"#{@server.addr[3]}:#{@server.addr[1]}"
end

def hits
@lock.synchronize { @hits }
end

def reset
@lock.synchronize { @hits = 0 }
end

def close
@thread&.kill
@server&.close
end

private

def accept_loop
loop do
client = @server.accept
@lock.synchronize { @hits += 1 }
drain_request(client)
client.write("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n")
client.close
end
rescue IOError, Errno::EBADF
# server closed
end

def drain_request(client)
content_length = 0
while (line = client.gets) && line != "\r\n"
content_length = line.split(':', 2).last.to_i if line =~ /\AContent-Length:/i
end
client.read(content_length) if content_length.positive?
end
end
Loading